basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, beforeEach, afterEach } from 'vitest';8import * as fs from 'node:fs';9import * as path from 'node:path';10import * as os from 'node:os';11import {12 updateSettingsFilePreservingFormat,13 applyUpdates,14} from './commentJson.js';15 16describe('commentJson', () => {17 let tempDir: string;18 let testFilePath: string;19 20 beforeEach(() => {21 // Create a temporary directory for test files22 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preserve-format-test-'));23 testFilePath = path.join(tempDir, 'settings.json');24 });25 26 afterEach(() => {27 // Clean up temporary directory28 if (fs.existsSync(tempDir)) {29 fs.rmSync(tempDir, { recursive: true, force: true });30 }31 });32 33 describe('updateSettingsFilePreservingFormat', () => {34 it('should preserve comments when updating settings', () => {35 const originalContent = `{36 // Model configuration37 "model": "gemini-2.5-pro",38 "ui": {39 // Theme setting40 "theme": "dark"41 }42 }`;43 44 fs.writeFileSync(testFilePath, originalContent, 'utf-8');45 46 updateSettingsFilePreservingFormat(testFilePath, {47 model: 'gemini-2.5-flash',48 });49 50 const updatedContent = fs.readFileSync(testFilePath, 'utf-8');51 52 expect(updatedContent).toContain('// Model configuration');53 expect(updatedContent).toContain('// Theme setting');54 expect(updatedContent).toContain('"model": "gemini-2.5-flash"');55 expect(updatedContent).toContain('"theme": "dark"');56 });57 58 it('should handle nested object updates', () => {59 const originalContent = `{60 "ui": {61 "theme": "dark",62 "showLineNumbers": true63 }64 }`;65 66 fs.writeFileSync(testFilePath, originalContent, 'utf-8');67 68 updateSettingsFilePreservingFormat(testFilePath, {69 ui: {70 theme: 'light',71 showLineNumbers: true,72 },73 });74 75 const updatedContent = fs.readFileSync(testFilePath, 'utf-8');76 expect(updatedContent).toContain('"theme": "light"');77 expect(updatedContent).toContain('"showLineNumbers": true');78 });79 80 it('should add new fields while preserving existing structure', () => {81 const originalContent = `{82 // Existing config83 "model": "gemini-2.5-pro"84 }`;85 86 fs.writeFileSync(testFilePath, originalContent, 'utf-8');87 88 updateSettingsFilePreservingFormat(testFilePath, {89 model: 'gemini-2.5-pro',90 newField: 'newValue',91 });92 93 const updatedContent = fs.readFileSync(testFilePath, 'utf-8');94 expect(updatedContent).toContain('// Existing config');95 expect(updatedContent).toContain('"newField": "newValue"');96 });97 98 it('should create file if it does not exist', () => {99 updateSettingsFilePreservingFormat(testFilePath, {100 model: 'gemini-2.5-pro',101 });102 103 expect(fs.existsSync(testFilePath)).toBe(true);104 const content = fs.readFileSync(testFilePath, 'utf-8');105 expect(content).toContain('"model": "gemini-2.5-pro"');106 });107 108 it('should handle complex real-world scenario', () => {109 const complexContent = `{110 // Settings111 "model": "gemini-2.5-pro",112 "mcpServers": {113 // Active server114 "context7": {115 "headers": {116 "API_KEY": "test-key" // API key117 }118 }119 }120 }`;121 122 fs.writeFileSync(testFilePath, complexContent, 'utf-8');123 124 updateSettingsFilePreservingFormat(testFilePath, {125 model: 'gemini-2.5-flash',126 mcpServers: {127 context7: {128 headers: {129 API_KEY: 'new-test-key',130 },131 },132 },133 newSection: {134 setting: 'value',135 },136 });137 138 const updatedContent = fs.readFileSync(testFilePath, 'utf-8');139 140 // Verify comments preserved141 expect(updatedContent).toContain('// Settings');142 expect(updatedContent).toContain('// Active server');143 expect(updatedContent).toContain('// API key');144 145 // Verify updates applied146 expect(updatedContent).toContain('"model": "gemini-2.5-flash"');147 expect(updatedContent).toContain('"newSection"');148 expect(updatedContent).toContain('"API_KEY": "new-test-key"');149 });150 151 it('should handle corrupted JSON files gracefully', () => {152 const corruptedContent = `{153 "model": "gemini-2.5-pro",154 "ui": {155 "theme": "dark"156 // Missing closing brace157 `;158 159 fs.writeFileSync(testFilePath, corruptedContent, 'utf-8');160 161 expect(() => {162 updateSettingsFilePreservingFormat(testFilePath, {163 model: 'gemini-2.5-flash',164 });165 }).not.toThrow();166 167 const unchangedContent = fs.readFileSync(testFilePath, 'utf-8');168 expect(unchangedContent).toBe(corruptedContent);169 });170 });171});172 173describe('applyUpdates', () => {174 it('should apply updates correctly', () => {175 const original = { a: 1, b: { c: 2 } };176 const updates = { b: { c: 3 } };177 const result = applyUpdates(original, updates);178 expect(result).toEqual({ a: 1, b: { c: 3 } });179 });180 it('should apply updates correctly when empty', () => {181 const original = { a: 1, b: { c: 2 } };182 const updates = { b: {} };183 const result = applyUpdates(original, updates);184 expect(result).toEqual({ a: 1, b: {} });185 });186 187 it('should replace the object at the exact replace path', () => {188 const original = {189 ui: { theme: 'dark' },190 mcpServers: {191 keep: { command: 'node' },192 remove: { command: 'python' },193 },194 };195 const updates = {196 mcpServers: {197 keep: { command: 'node' },198 },199 };200 201 const result = applyUpdates(original, updates, false, ['mcpServers']);202 203 expect(result).toEqual({204 ui: { theme: 'dark' },205 mcpServers: {206 keep: { command: 'node' },207 },208 });209 });210 211 it('should replace a nested object while preserving siblings', () => {212 const original = {213 ui: {214 theme: { color: 'red', mode: 'dark' },215 fontSize: 14,216 },217 };218 const updates = {219 ui: {220 theme: { color: 'blue' },221 },222 };223 224 const result = applyUpdates(original, updates, false, ['ui', 'theme']);225 226 expect(result).toEqual({227 ui: {228 theme: { color: 'blue' },229 fontSize: 14,230 },231 });232 });233 234 it('should ignore prototype-pollution keys in updates', () => {235 const original = {};236 const updates = JSON.parse(237 '{"safe":true,"__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}},"nested":{"prototype":{"polluted":true},"keep":1}}',238 ) as Record<string, unknown>;239 240 const result = applyUpdates(original, updates);241 242 expect(result).toEqual({243 safe: true,244 nested: {245 keep: 1,246 },247 });248 expect(Object.prototype).not.toHaveProperty('polluted');249 });250});251 252describe('migration write-back via updateSettingsFilePreservingFormat', () => {253 let tempDir: string;254 let testFilePath: string;255 256 beforeEach(() => {257 tempDir = fs.mkdtempSync(258 path.join(os.tmpdir(), 'migration-writeback-test-'),259 );260 testFilePath = path.join(tempDir, 'settings.json');261 });262 263 afterEach(() => {264 if (fs.existsSync(tempDir)) {265 fs.rmSync(tempDir, { recursive: true, force: true });266 }267 });268 269 it('should preserve comments on keys that exist in both original and updates', () => {270 const original = `{271 // My model choice272 "model": "gemini-2.5-pro",273 "ui": {274 // Theme preference275 "theme": "dark"276 }277}`;278 279 fs.writeFileSync(testFilePath, original, 'utf-8');280 281 // Runtime update: only changes model, keeps ui282 const updates = {283 model: 'gemini-2.5-flash',284 ui: {285 theme: 'dark',286 },287 };288 289 updateSettingsFilePreservingFormat(testFilePath, updates);290 291 const result = fs.readFileSync(testFilePath, 'utf-8');292 293 // Comments on preserved keys survive294 expect(result).toContain('// My model choice');295 expect(result).toContain('// Theme preference');296 // Updated value applied297 expect(result).toContain('"model": "gemini-2.5-flash"');298 });299 300 it('should add new keys while preserving existing comments', () => {301 const original = `{302 // API configuration303 "model": "gemini-2.5-flash"304}`;305 306 fs.writeFileSync(testFilePath, original, 'utf-8');307 308 const updates = {309 model: 'gemini-2.5-flash',310 $version: 3,311 };312 313 updateSettingsFilePreservingFormat(testFilePath, updates);314 315 const result = fs.readFileSync(testFilePath, 'utf-8');316 317 // Original comment preserved318 expect(result).toContain('// API configuration');319 // New key added320 expect(result).toContain('$version');321 });322 323 it('should preserve inline comments and trailing commas', () => {324 const original = `{325 "model": "gemini-2.5-pro", // inline comment326 "ui": {327 "theme": "dark",328 },329}`;330 331 fs.writeFileSync(testFilePath, original, 'utf-8');332 333 const updates = {334 model: 'gemini-2.5-flash',335 ui: {336 theme: 'light',337 },338 };339 340 updateSettingsFilePreservingFormat(testFilePath, updates);341 342 const result = fs.readFileSync(testFilePath, 'utf-8');343 344 // Inline comment preserved345 expect(result).toContain('// inline comment');346 // Values updated347 expect(result).toContain('"model": "gemini-2.5-flash"');348 expect(result).toContain('"theme": "light"');349 });350 351 it('should remove nested zombie keys in sync mode', () => {352 // Simulate a V2 settings file with deprecated disable* keys inside nested objects353 const v2Settings = `{354 "general": {355 // Auto-update setting356 "disableAutoUpdate": true,357 "disableUpdateNag": true358 },359 "ui": {360 "theme": "dark"361 },362 "$version": 2363}`;364 365 fs.writeFileSync(testFilePath, v2Settings, 'utf-8');366 367 // Migrated V3 settings: disable* keys removed, enable* keys added368 const migratedSettings = {369 general: {370 enableAutoUpdate: false,371 },372 ui: {373 theme: 'dark',374 },375 $version: 3,376 };377 378 const result = updateSettingsFilePreservingFormat(379 testFilePath,380 migratedSettings,381 true,382 );383 384 expect(result).toBe(true);385 const content = fs.readFileSync(testFilePath, 'utf-8');386 387 // Deprecated nested keys must be removed388 expect(content).not.toContain('disableAutoUpdate');389 expect(content).not.toContain('disableUpdateNag');390 // New keys must be present391 expect(content).toContain('enableAutoUpdate');392 expect(content).toContain('$version');393 // Unrelated keys preserved394 expect(content).toContain('"theme": "dark"');395 });396 397 it('should remove top-level zombie keys in sync mode', () => {398 const original = `{399 "theme": "dark",400 "model": "gemini-2.5-pro",401 "deprecatedKey": "zombie"402}`;403 404 fs.writeFileSync(testFilePath, original, 'utf-8');405 406 const migratedSettings = {407 model: 'gemini-2.5-flash',408 $version: 3,409 };410 411 const result = updateSettingsFilePreservingFormat(412 testFilePath,413 migratedSettings,414 true,415 );416 417 expect(result).toBe(true);418 const content = fs.readFileSync(testFilePath, 'utf-8');419 420 // Top-level zombie removed421 expect(content).not.toContain('theme');422 expect(content).not.toContain('deprecatedKey');423 // Migrated keys present424 expect(content).toContain('"model": "gemini-2.5-flash"');425 expect(content).toContain('$version');426 });427 428 it('should preserve unrelated keys in nested objects during sync', () => {429 // The migrated object represents the full desired state — migrations430 // preserve unrelated keys, so they appear in the migrated output.431 const original = `{432 "general": {433 "disableAutoUpdate": true,434 "someOtherSetting": "keep-me"435 }436}`;437 438 fs.writeFileSync(testFilePath, original, 'utf-8');439 440 // After migration: disableAutoUpdate removed, enableAutoUpdate added,441 // someOtherSetting preserved (migrations carry forward unrelated keys)442 const migratedSettings = {443 general: {444 enableAutoUpdate: false,445 someOtherSetting: 'keep-me',446 },447 };448 449 const result = updateSettingsFilePreservingFormat(450 testFilePath,451 migratedSettings,452 true,453 );454 455 expect(result).toBe(true);456 const content = fs.readFileSync(testFilePath, 'utf-8');457 458 // Deprecated key removed459 expect(content).not.toContain('disableAutoUpdate');460 // New key added461 expect(content).toContain('enableAutoUpdate');462 // Unrelated key in same nested object preserved463 expect(content).toContain('someOtherSetting');464 expect(content).toContain('keep-me');465 });466 467 it('should remove all keys when sync=true with empty updates object', () => {468 // Documents the behavior: sync mode with empty updates wipes all keys.469 // This is intentional for migrations that restructure the entire file.470 const original = `{471 "a": 1,472 "b": { "c": 2 }473}`;474 fs.writeFileSync(testFilePath, original, 'utf-8');475 476 const result = updateSettingsFilePreservingFormat(testFilePath, {}, true);477 478 expect(result).toBe(true);479 const content = fs.readFileSync(testFilePath, 'utf-8');480 expect(content).not.toContain('"a"');481 expect(content).not.toContain('"b"');482 expect(content).not.toContain('"c"');483 });484});485 