fix(cli): keep v5 settings migration idempotent (#5676)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
Qwen Code CI / Integration Tests (No-AK Smoke) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

This commit is contained in:
易良 2026-06-23 01:28:48 +08:00 committed by GitHub
parent 01d28a1481
commit 2fd2104fa1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 63 additions and 45 deletions

View file

@ -508,17 +508,17 @@ function setupAcpTest(
expect(modelOption).toBeDefined();
expect(modelOption!.currentValue).toBeTruthy();
// Test: Set model using set_config_option
// Use openai model to avoid auth issues
const openaiModel = newSession.models.availableModels.find((model) =>
model.modelId.includes('openai'),
);
expect(openaiModel).toBeDefined();
const modelId = modelOption!.currentValue;
expect(
newSession.models.availableModels.some(
(model) => model.modelId === modelId,
),
).toBe(true);
const setModelResult = (await sendRequest('session/set_config_option', {
sessionId: newSession.sessionId,
configId: 'model',
value: openaiModel!.modelId,
value: modelId,
})) as {
configOptions: Array<{
id: string;
@ -535,7 +535,7 @@ function setupAcpTest(
(opt) => opt.id === 'model',
);
expect(updatedModelOption).toBeDefined();
expect(updatedModelOption!.currentValue).toBe(openaiModel!.modelId);
expect(updatedModelOption!.currentValue).toBe(modelId);
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));

View file

@ -96,7 +96,9 @@ describe('file-system', () => {
await rig.setup('should correctly handle file paths with spaces');
const fileName = 'my test file.txt';
const result = await rig.run(`write "hello" to "${fileName}"`);
const result = await rig.run(
`Use write_file to write exactly "hello" to "${fileName}".`,
);
const foundToolCall = await rig.waitForToolCall('write_file');
if (!foundToolCall) {
@ -108,7 +110,7 @@ describe('file-system', () => {
).toBeTruthy();
const newFileContent = rig.readFile(fileName);
expect(newFileContent).toBe('hello');
expect(newFileContent.trimEnd()).toBe('hello');
});
it('should perform a read-then-write sequence', async () => {

View file

@ -30,6 +30,8 @@ import {
} from 'node:fs';
import { join, resolve } from 'node:path';
const CURRENT_SETTINGS_VERSION = 5;
// Helper: list files under a directory recursively, returning relative paths
function listFilesRecursive(dir: string, base = dir): string[] {
if (!existsSync(dir)) return [];
@ -217,9 +219,7 @@ describe('QWEN_HOME environment variable', () => {
);
const migrated = JSON.parse(migratedRaw) as Record<string, unknown>;
// Migration should have bumped the version to the current SETTINGS_VERSION
// (packages/cli/src/config/settings.ts). Update this when the schema bumps.
expect(migrated['$version']).toBe(4);
expect(migrated['$version']).toBe(CURRENT_SETTINGS_VERSION);
});
});
@ -249,10 +249,16 @@ describe('QWEN_HOME environment variable', () => {
process.env['QWEN_HOME'] = customConfigDir;
// Seed QWEN_HOME with the current schema version so it shouldn't migrate.
// Bump alongside SETTINGS_VERSION in packages/cli/src/config/settings.ts.
writeFileSync(
join(customConfigDir, 'settings.json'),
JSON.stringify({ $version: 4, customKey: 'in-global-dir' }, null, 2),
JSON.stringify(
{
$version: CURRENT_SETTINGS_VERSION,
customKey: 'in-global-dir',
},
null,
2,
),
);
// Overwrite the workspace settings.json with V1 format so migration is observable
@ -284,14 +290,14 @@ describe('QWEN_HOME environment variable', () => {
}
// The workspace settings.json must have been migrated to the current
// SETTINGS_VERSION — proving the CLI read it from the workspace dir, not
// from QWEN_HOME. Update the version when the schema bumps.
// settings version, proving the CLI read it from the workspace dir, not
// from QWEN_HOME.
const workspaceRaw = readFileSync(workspaceSettingsPath, 'utf-8');
const workspaceSettings = JSON.parse(workspaceRaw) as Record<
string,
unknown
>;
expect(workspaceSettings['$version']).toBe(4);
expect(workspaceSettings['$version']).toBe(CURRENT_SETTINGS_VERSION);
expect(workspaceSettings['customWorkspaceKey']).toBe('workspace-value');
// The QWEN_HOME settings.json must be unchanged (still at the version we wrote)

View file

@ -27,18 +27,16 @@ const {
v3GitCoAuthorBooleanSettings,
} = workspacesSettings;
const CURRENT_SETTINGS_VERSION = 5;
/**
* Integration tests for settings migration chain (V1 -> V2 -> V3 -> V4)
* Integration tests for settings migration chain.
*
* These tests verify that:
* 1. V1 settings are automatically migrated to V4 on CLI startup
* 2. V2 settings are automatically migrated to V4 on CLI startup
* 3. V3 settings are automatically migrated to V4 on CLI startup
* 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)
*
* The numeric assertions use the literal `4` to match
* `SETTINGS_VERSION`; bump that constant and the literal together
* when adding a future migration.
*/
describe('settings-migration', () => {
let rig: TestRig;
@ -99,8 +97,7 @@ describe('settings-migration', () => {
// Read migrated settings
const migratedSettings = readSettingsFile(rig);
// Verify migration to V4 (current SETTINGS_VERSION)
expect(migratedSettings['$version']).toBe(4);
expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION);
expect(migratedSettings['ui']).toEqual({
theme: 'dark',
hideTips: false,
@ -142,7 +139,7 @@ describe('settings-migration', () => {
const migratedSettings = readSettingsFile(rig);
// Expected output based on stable test output
expect(migratedSettings['$version']).toBe(4);
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'] });
@ -166,8 +163,7 @@ describe('settings-migration', () => {
// Read migrated settings
const migratedSettings = readSettingsFile(rig);
// Should be migrated to V4
expect(migratedSettings['$version']).toBe(4);
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');
@ -194,7 +190,7 @@ describe('settings-migration', () => {
const migratedSettings = readSettingsFile(rig);
// Expected output based on stable test output
expect(migratedSettings['$version']).toBe(4);
expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION);
expect(migratedSettings['model']).toEqual({ name: 'qwen-plus' });
expect(migratedSettings['ui']).toEqual({
hideWindowTitle: true,
@ -230,8 +226,7 @@ describe('settings-migration', () => {
// Read migrated settings
const migratedSettings = readSettingsFile(rig);
// Verify migration to V4 (current SETTINGS_VERSION)
expect(migratedSettings['$version']).toBe(4);
expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION);
// Verify disable* -> enable* conversion with inversion
expect(
@ -307,8 +302,7 @@ describe('settings-migration', () => {
// Read migrated settings
const migratedSettings = readSettingsFile(rig);
// Should be updated to V4 version
expect(migratedSettings['$version']).toBe(4);
expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION);
// Other settings should remain unchanged
expect(migratedSettings['ui']).toEqual({ theme: 'dark' });
expect(migratedSettings['model']).toEqual({ name: 'gemini' });
@ -335,7 +329,7 @@ describe('settings-migration', () => {
const migratedSettings = readSettingsFile(rig);
// Version metadata should still be normalized to current version
expect(migratedSettings['$version']).toBe(4);
expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION);
// Existing user content should be preserved
expect(migratedSettings['customOnlyKey']).toBe('value');
});
@ -377,7 +371,7 @@ describe('settings-migration', () => {
const migratedSettings = readSettingsFile(rig);
// Coercible strings are migrated; invalid disable* values are removed.
expect(migratedSettings['$version']).toBe(4);
expect(migratedSettings['$version']).toBe(CURRENT_SETTINGS_VERSION);
expect(migratedSettings['general']).toEqual({
enableAutoUpdate: false,
});
@ -442,7 +436,7 @@ describe('settings-migration', () => {
const migratedSettings = readSettingsFile(rig);
// Expected output based on stable test output
expect(migratedSettings['$version']).toBe(4);
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
@ -509,7 +503,7 @@ describe('settings-migration', () => {
// 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(4);
expect(finalSettings['$version']).toBe(CURRENT_SETTINGS_VERSION);
expect(
(finalSettings['general'] as Record<string, unknown>)?.[
'disableAutoUpdate'
@ -563,7 +557,7 @@ describe('settings-migration', () => {
const finalSettings = readSettingsFile(rig);
expect(finalSettings['$version']).toBe(4);
expect(finalSettings['$version']).toBe(CURRENT_SETTINGS_VERSION);
expect(
(finalSettings['general'] as Record<string, unknown>)?.['gitCoAuthor'],
).toEqual({ commit: false, pr: false });

View file

@ -33,11 +33,11 @@ describe('V4ToV5Migration', () => {
).toBe(false);
});
it('returns false for V4 settings without modelProviders', () => {
expect(migration.shouldMigrate({ $version: 4 })).toBe(false);
it('returns true for V4 settings without modelProviders', () => {
expect(migration.shouldMigrate({ $version: 4 })).toBe(true);
});
it('returns false for V4 settings with already-object modelProviders', () => {
it('returns true for V4 settings with already-object modelProviders', () => {
expect(
migration.shouldMigrate({
$version: 4,
@ -45,7 +45,7 @@ describe('V4ToV5Migration', () => {
openai: { protocol: 'openai', models: [{ id: 'gpt-4o' }] },
},
}),
).toBe(false);
).toBe(true);
});
it('returns false for non-object input', () => {
@ -92,6 +92,19 @@ describe('V4ToV5Migration', () => {
expect(warnings).toEqual([]);
});
it('bumps V4 settings without modelProviders to V5', () => {
const { settings, warnings } = migration.migrate(
{ $version: 4, custom: true },
'user',
) as {
settings: Record<string, unknown>;
warnings: string[];
};
expect(settings).toEqual({ $version: 5, custom: true });
expect(warnings).toEqual([]);
});
it.each([
['openai', 'openai'],
['qwen-oauth', 'qwen-oauth'],

View file

@ -42,6 +42,9 @@ export class V4ToV5Migration implements SettingsMigration {
if (s['$version'] !== 4 && s['$version'] !== undefined) {
return false;
}
if (s['$version'] === 4) {
return true;
}
const modelProviders = s['modelProviders'];
if (typeof modelProviders !== 'object' || modelProviders === null) {
return false;