/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from '../test-helper.js'; import { writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; // Import settings fixtures from unified workspace file import workspacesSettings from '../fixtures/settings-migration/workspaces.json' with { type: 'json' }; const { v1Settings, v1ComplexSettings, v1ArrayAndNullSettings, v1ParentCollisionSettings, v1VersionStringSettings, v2Settings, v2MinimalSettings, v2BooleanStringSettings, v2PreexistingEnableSettings, v3LegacyDisableSettings, v999FutureVersionSettings, v3GitCoAuthorBooleanSettings, } = workspacesSettings; // Keep in sync with SETTINGS_VERSION in packages/cli/src/config/settings.ts. const CURRENT_SETTINGS_VERSION = 4; /** * Integration tests for settings migration chain. * * These tests verify that: * 1. V1 settings are automatically migrated to current settings on CLI startup * 2. V2 settings are automatically migrated to current settings on CLI startup * 3. V3 settings are automatically migrated to current settings on CLI startup * 4. Migration is idempotent (running multiple times produces same result) */ describe('settings-migration', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { await rig.cleanup(); }); /** * Helper to write settings file for an existing test rig. * This overwrites the settings file created by rig.setup(). */ const overwriteSettingsFile = ( testRig: TestRig, settings: Record, ) => { const qwenDir = join( (testRig as unknown as { testDir: string }).testDir, '.qwen', ); writeFileSync( join(qwenDir, 'settings.json'), JSON.stringify(settings, null, 2), ); }; /** * Helper to read settings file from the test directory */ const readSettingsFile = (testRig: TestRig): Record => { const qwenDir = join( (testRig as unknown as { testDir: string }).testDir, '.qwen', ); const content = readFileSync(join(qwenDir, 'settings.json'), 'utf-8'); return JSON.parse(content) as Record; }; describe('V1 settings migration', () => { it('should migrate V1 settings forward through the chain on CLI startup', async () => { rig.setup('v1-to-v3-migration'); // Write V1 settings directly (overwrites the one created by setup) overwriteSettingsFile(rig, v1Settings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls. // `--help` is intentionally side-effect-free and does not load settings. try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail, we just need the settings file to be processed } // Read migrated settings const migratedSettings = readSettingsFile(rig); expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); expect(migratedSettings['ui']).toEqual({ theme: 'dark', hideTips: false, accessibility: { enableLoadingPhrases: false, }, }); expect(migratedSettings['model']).toEqual({ name: 'gemini' }); expect(migratedSettings['tools']).toEqual({ autoAccept: true }); expect(migratedSettings['general']).toEqual({ vimMode: true, checkpointing: true, enableAutoUpdate: false, }); expect(migratedSettings['mcpServers']).toEqual({ fetch: { command: 'node', args: ['fetch-server.js'], }, }); // Custom user settings should be preserved expect(migratedSettings['customUserSetting']).toBe('preserved-value'); }); it('should handle V1 settings with arrays and null values', async () => { rig.setup('v1-array-and-null-migration'); // Use fixture with arrays, null values, and string booleans overwriteSettingsFile(rig, v1ArrayAndNullSettings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } // Read migrated settings const migratedSettings = readSettingsFile(rig); // Expected output based on stable test output expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); expect(migratedSettings['tools']).toEqual({ autoAccept: false }); expect(migratedSettings['context']).toEqual({ includeDirectories: [] }); expect(migratedSettings['model']).toEqual({ name: ['gemini', 'claude'] }); expect(migratedSettings['ui']).toEqual({ theme: null }); expect(migratedSettings['customArray']).toEqual([{ key: 1 }]); }); it('should handle V1 settings with parent key collision', async () => { rig.setup('v1-parent-collision-migration'); // Use fixture where V1 flat keys (ui, general) conflict with V2/V3 nested structure overwriteSettingsFile(rig, v1ParentCollisionSettings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } // Read migrated settings const migratedSettings = readSettingsFile(rig); expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); // Legacy string values for ui/general should be preserved as-is (user data) expect(migratedSettings['ui']).toBe('legacy-ui-string'); expect(migratedSettings['general']).toBe('legacy-general-string'); // Custom nested objects should be preserved expect(migratedSettings['notes']).toEqual({ fromUser: 'preserve-custom', }); }); it('should handle V1 settings with string version and string booleans', async () => { rig.setup('v1-string-version-migration'); // Use fixture with $version as string and string boolean values overwriteSettingsFile(rig, v1VersionStringSettings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } // Read migrated settings const migratedSettings = readSettingsFile(rig); // Expected output based on stable test output expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); expect(migratedSettings['model']).toEqual({ name: 'qwen-plus' }); expect(migratedSettings['ui']).toEqual({ hideWindowTitle: true, theme: 'light', }); // String "false" for disableAutoUpdate is treated as truthy (non-empty string) // So enableAutoUpdate = !truthy = false, but output shows true // This suggests string "false" is parsed as boolean false expect( (migratedSettings['general'] as Record)?.[ 'enableAutoUpdate' ], ).toBe(true); // Custom sections should be preserved expect(migratedSettings['customSection']).toEqual({ keepMe: true }); }); }); describe('V2 settings migration', () => { it('should migrate V2 settings forward through the chain on CLI startup', async () => { rig.setup('v2-to-v3-migration'); // Write V2 settings directly (overwrites the one created by setup) overwriteSettingsFile(rig, v2Settings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } // Read migrated settings const migratedSettings = readSettingsFile(rig); expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); // Verify disable* -> enable* conversion with inversion expect( ( (migratedSettings['ui'] as Record)?.[ 'accessibility' ] as Record )?.['enableLoadingPhrases'], ).toBe(true); expect( (migratedSettings['general'] as Record)?.[ 'enableAutoUpdate' ], ).toBe(true); expect( ( (migratedSettings['context'] as Record)?.[ 'fileFiltering' ] as Record )?.['enableFuzzySearch'], ).toBe(false); // Verify old disable* keys are removed expect( (migratedSettings['general'] as Record)?.[ 'disableAutoUpdate' ], ).toBeUndefined(); expect( (migratedSettings['general'] as Record)?.[ 'disableUpdateNag' ], ).toBeUndefined(); expect( ( (migratedSettings['ui'] as Record)?.[ 'accessibility' ] as Record )?.['disableLoadingPhrases'], ).toBeUndefined(); expect( ( (migratedSettings['context'] as Record)?.[ 'fileFiltering' ] as Record )?.['disableFuzzySearch'], ).toBeUndefined(); }); it('should handle V2 settings without any disable* keys', async () => { rig.setup('v2-clean-migration'); // Use minimal V2 fixture and add ui/model settings without disable* keys const cleanV2Settings = { ...v2MinimalSettings, ui: { theme: 'dark', }, model: { name: 'gemini', }, }; overwriteSettingsFile(rig, cleanV2Settings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } // Read migrated settings const migratedSettings = readSettingsFile(rig); expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); // Other settings should remain unchanged expect(migratedSettings['ui']).toEqual({ theme: 'dark' }); expect(migratedSettings['model']).toEqual({ name: 'gemini' }); }); it('should normalize legacy numeric version with no migratable keys to current version', async () => { rig.setup('legacy-version-normalization'); // Use v1Settings fixture as base but with only custom key const legacyVersionWithoutMigratableKeys = { $version: 1, customOnlyKey: 'value', }; overwriteSettingsFile(rig, legacyVersionWithoutMigratableKeys); // Run CLI with `mcp list` to trigger settings load/write path try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } const migratedSettings = readSettingsFile(rig); // Version metadata should still be normalized to current version expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); // Existing user content should be preserved expect(migratedSettings['customOnlyKey']).toBe('value'); }); it('should coerce valid string booleans and remove invalid deprecated keys while bumping V2 forward through the chain', async () => { rig.setup('v2-non-boolean-disable-values-migration'); // Cover both coercible string booleans and invalid non-boolean values: // - "TRUE"/"false" should be coerced and migrated // - invalid values should have deprecated disable* keys removed const mixedNonBooleanDisableSettings = { ...v2BooleanStringSettings, ui: { accessibility: { disableLoadingPhrases: 'yes', }, }, context: { fileFiltering: { disableFuzzySearch: null, }, }, model: { generationConfig: { disableCacheControl: [1], }, }, }; overwriteSettingsFile(rig, mixedNonBooleanDisableSettings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } // Read migrated settings const migratedSettings = readSettingsFile(rig); // Coercible strings are migrated; invalid disable* values are removed. expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); expect(migratedSettings['general']).toEqual({ enableAutoUpdate: false, }); expect( ( (migratedSettings['ui'] as Record)?.[ 'accessibility' ] as Record )?.['disableLoadingPhrases'], ).toBeUndefined(); expect( ( (migratedSettings['ui'] as Record)?.[ 'accessibility' ] as Record )?.['enableLoadingPhrases'], ).toBeUndefined(); expect( ( (migratedSettings['context'] as Record)?.[ 'fileFiltering' ] as Record )?.['disableFuzzySearch'], ).toBeUndefined(); expect( ( (migratedSettings['context'] as Record)?.[ 'fileFiltering' ] as Record )?.['enableFuzzySearch'], ).toBeUndefined(); expect( ( (migratedSettings['model'] as Record)?.[ 'generationConfig' ] as Record )?.['disableCacheControl'], ).toBeUndefined(); expect( ( (migratedSettings['model'] as Record)?.[ 'generationConfig' ] as Record )?.['enableCacheControl'], ).toBeUndefined(); }); it('should handle V2 settings with preexisting enable* keys', async () => { rig.setup('v2-preexisting-enable-migration'); // Use fixture with both disable* and enable* keys overwriteSettingsFile(rig, v2PreexistingEnableSettings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } // Read migrated settings const migratedSettings = readSettingsFile(rig); // Expected output based on stable test output expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); // Migration converts disable* to enable* by inverting the value // disableAutoUpdate: false -> enableAutoUpdate: true (inverted) // But disableUpdateNag: true may affect the consolidation expect( (migratedSettings['general'] as Record)?.[ 'enableAutoUpdate' ], ).toBe(false); // disableLoadingPhrases: true -> enableLoadingPhrases: false (inverted) expect( ( (migratedSettings['ui'] as Record)?.[ 'accessibility' ] as Record )?.['enableLoadingPhrases'], ).toBe(false); // disableFuzzySearch: false -> enableFuzzySearch: true (inverted) expect( ( (migratedSettings['context'] as Record)?.[ 'fileFiltering' ] as Record )?.['enableFuzzySearch'], ).toBe(true); // disableCacheControl: true -> enableCacheControl: false (inverted) expect( ( (migratedSettings['model'] as Record)?.[ 'generationConfig' ] as Record )?.['enableCacheControl'], ).toBe(false); // Old disable* keys should be removed expect( (migratedSettings['general'] as Record)?.[ 'disableAutoUpdate' ], ).toBeUndefined(); expect( (migratedSettings['general'] as Record)?.[ 'disableUpdateNag' ], ).toBeUndefined(); }); }); describe('V3 settings handling', () => { it('should handle V3 settings with legacy disable* keys', async () => { rig.setup('v3-legacy-disable-keys'); // Use fixture with V3 format but still has legacy disable* keys overwriteSettingsFile(rig, v3LegacyDisableSettings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } // Read settings const finalSettings = readSettingsFile(rig); // V3 → V4 migration bumps the version; V3→V4 only touches // general.gitCoAuthor, so unrelated legacy disable* keys remain as-is // (V2→V3 ran on original V3 load, not re-applied here). expect(finalSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); expect( (finalSettings['general'] as Record)?.[ 'disableAutoUpdate' ], ).toBe(true); expect( ( (finalSettings['ui'] as Record)?.[ 'accessibility' ] as Record )?.['disableLoadingPhrases'], ).toBe(false); // Existing enable* keys should be preserved expect( (finalSettings['general'] as Record)?.[ 'enableAutoUpdate' ], ).toBe(false); expect( ( (finalSettings['ui'] as Record)?.[ 'accessibility' ] as Record )?.['enableLoadingPhrases'], ).toBe(true); // Custom settings should be preserved expect(finalSettings['custom']).toEqual({ note: 'should remain unchanged in v3', }); }); // V3 used to allow `general.gitCoAuthor: `. The V3→V4 // migration must expand that boolean into the new // `{ commit, pr }` object shape so the user's stored opt-out // doesn't get silently overwritten by the schema defaults // (which default both sub-toggles to `true`) on the next save. // The unit test in `v3-to-v4.test.ts` already pins the // migration body, but without an end-to-end fixture the real // CLI load → migrate → write path could regress without // this suite noticing. it('should expand legacy boolean general.gitCoAuthor: false through V3 → V4', async () => { rig.setup('v3-gitcoauthor-boolean'); overwriteSettingsFile(rig, v3GitCoAuthorBooleanSettings); try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } const finalSettings = readSettingsFile(rig); expect(finalSettings['$version']).toBe(CURRENT_SETTINGS_VERSION); expect( (finalSettings['general'] as Record)?.['gitCoAuthor'], ).toEqual({ commit: false, pr: false }); // Sibling general.* keys must survive the migration unchanged. expect( (finalSettings['general'] as Record)?.[ 'disableAutoUpdate' ], ).toBe(true); // And so must unrelated top-level sections. expect(finalSettings['custom']).toEqual({ note: 'preserve me through v3->v4', }); }); }); describe('Future version settings handling', () => { it('should not modify future version settings', async () => { rig.setup('v999-future-version'); // Use fixture with future version ($version: 999) overwriteSettingsFile(rig, v999FutureVersionSettings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } // Read settings const finalSettings = readSettingsFile(rig); // Future version should remain unchanged expect(finalSettings['$version']).toBe(999); expect(finalSettings['theme']).toBe('dark'); expect(finalSettings['model']).toBe('future-model'); expect(finalSettings['experimentalFlag']).toEqual({ enabled: true }); // disableAutoUpdate should remain as-is since migration doesn't apply expect(finalSettings['disableAutoUpdate']).toBe(true); }); }); describe('Migration idempotency', () => { it('should produce consistent results when run multiple times on V1 settings', async () => { rig.setup('v1-idempotency'); overwriteSettingsFile(rig, v1Settings); // Run CLI multiple times with `mcp list` try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } const firstRunSettings = readSettingsFile(rig); try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } const secondRunSettings = readSettingsFile(rig); try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } const thirdRunSettings = readSettingsFile(rig); // All runs should produce identical results expect(secondRunSettings).toEqual(firstRunSettings); expect(thirdRunSettings).toEqual(firstRunSettings); }); }); describe('Complex migration scenarios', () => { it('should preserve custom user settings during full migration chain', async () => { rig.setup('preserve-custom-settings'); // Use v1ComplexSettings fixture which has custom user settings overwriteSettingsFile(rig, v1ComplexSettings); // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } // Read migrated settings const migratedSettings = readSettingsFile(rig); // Custom keys should be preserved (v1ComplexSettings has 'custom-value' and { nested: true, items: [1, 2, 3] }) expect(migratedSettings['myCustomKey']).toBe('custom-value'); expect(migratedSettings['anotherCustomSetting']).toEqual({ nested: true, items: [1, 2, 3], }); }); }); });