mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
267 lines
8.3 KiB
TypeScript
267 lines
8.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import {
|
|
loadMigrationCheckData,
|
|
migrate,
|
|
resolveManagedBackupPath,
|
|
rollback,
|
|
} from '../../../src/config/migration-manager';
|
|
import { loadUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
|
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
|
|
|
|
describe('migration-manager legacy kimi compatibility', () => {
|
|
let tempHome: string;
|
|
let ccsDir: string;
|
|
let originalCcsHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-migration-manager-test-'));
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
process.env.CCS_HOME = tempHome;
|
|
ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalCcsHome !== undefined) {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
|
|
if (fs.existsSync(tempHome)) {
|
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('prefers explicit canonical km profile over legacy kimi when both exist', async () => {
|
|
const kmSettingsPath = path.join(ccsDir, 'km.settings.json');
|
|
const kimiSettingsPath = path.join(ccsDir, 'kimi.settings.json');
|
|
|
|
fs.writeFileSync(kmSettingsPath, JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-km' } }));
|
|
fs.writeFileSync(
|
|
kimiSettingsPath,
|
|
JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-kimi' } })
|
|
);
|
|
|
|
// Intentionally place legacy alias first to verify deterministic behavior.
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'config.json'),
|
|
JSON.stringify(
|
|
{
|
|
profiles: {
|
|
kimi: kimiSettingsPath,
|
|
km: kmSettingsPath,
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
const result = await migrate(true);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(
|
|
result.migratedFiles.some((entry) =>
|
|
entry.includes(
|
|
`config.json.profiles.km → config.yaml.profiles.km (settings: ${kmSettingsPath})`
|
|
)
|
|
)
|
|
).toBe(true);
|
|
expect(
|
|
result.migratedFiles.some((entry) => entry.includes(`(settings: ${kimiSettingsPath})`))
|
|
).toBe(false);
|
|
expect(
|
|
result.warnings.some((warning) =>
|
|
warning.includes(
|
|
'Skipped kimi: canonical profile "km" exists in config.json with different settings'
|
|
)
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it('renames case-variant legacy Kimi profile key to km', async () => {
|
|
const kimiSettingsPath = path.join(ccsDir, 'kimi.settings.json');
|
|
fs.writeFileSync(
|
|
kimiSettingsPath,
|
|
JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-kimi-case-variant' } })
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'config.json'),
|
|
JSON.stringify(
|
|
{
|
|
profiles: {
|
|
Kimi: kimiSettingsPath,
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
const result = await migrate(true);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(
|
|
result.migratedFiles.some((entry) =>
|
|
entry.includes('config.json.profiles.Kimi → config.yaml.profiles.km')
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it('treats legacy kimi profile as migrated when unified config already has km', () => {
|
|
const unifiedConfig = createEmptyUnifiedConfig();
|
|
unifiedConfig.profiles.km = {
|
|
type: 'api',
|
|
settings: '~/.ccs/km.settings.json',
|
|
};
|
|
saveUnifiedConfig(unifiedConfig);
|
|
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'config.json'),
|
|
JSON.stringify(
|
|
{
|
|
profiles: {
|
|
kimi: '~/.ccs/kimi.settings.json',
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
const checkData = loadMigrationCheckData();
|
|
expect(checkData.needsMigration).toBe(false);
|
|
});
|
|
|
|
it('migrates account context metadata from profiles.json', async () => {
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'profiles.json'),
|
|
JSON.stringify(
|
|
{
|
|
default: 'work',
|
|
profiles: {
|
|
work: {
|
|
type: 'account',
|
|
created: '2026-02-01T00:00:00.000Z',
|
|
last_used: null,
|
|
context_mode: 'shared',
|
|
context_group: 'sprint-a',
|
|
},
|
|
personal: {
|
|
type: 'account',
|
|
created: '2026-02-02T00:00:00.000Z',
|
|
last_used: null,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
const result = await migrate(false);
|
|
expect(result.success).toBe(true);
|
|
|
|
const unified = loadUnifiedConfig();
|
|
expect(unified).toBeTruthy();
|
|
expect(unified?.accounts.work.context_mode).toBe('shared');
|
|
expect(unified?.accounts.work.context_group).toBe('sprint-a');
|
|
expect(unified?.accounts.work.continuity_mode).toBe('standard');
|
|
expect(unified?.accounts.personal.context_mode).toBe('isolated');
|
|
expect(unified?.accounts.personal.context_group).toBeUndefined();
|
|
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
|
|
});
|
|
|
|
it('normalizes valid legacy shared groups and drops invalid ones during migration', async () => {
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'profiles.json'),
|
|
JSON.stringify(
|
|
{
|
|
default: 'work',
|
|
profiles: {
|
|
work: {
|
|
type: 'account',
|
|
created: '2026-02-01T00:00:00.000Z',
|
|
last_used: null,
|
|
context_mode: 'shared',
|
|
context_group: 'Sprint-A',
|
|
},
|
|
broken: {
|
|
type: 'account',
|
|
created: '2026-02-02T00:00:00.000Z',
|
|
last_used: null,
|
|
context_mode: 'shared',
|
|
context_group: '###',
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
const result = await migrate(false);
|
|
expect(result.success).toBe(true);
|
|
expect(
|
|
result.warnings.some((warning) =>
|
|
warning.includes('Skipped invalid context group for account "broken"')
|
|
)
|
|
).toBe(true);
|
|
|
|
const unified = loadUnifiedConfig();
|
|
expect(unified?.accounts.work.context_group).toBe('sprint-a');
|
|
expect(unified?.accounts.broken.context_mode).toBe('shared');
|
|
expect(unified?.accounts.broken.context_group).toBeUndefined();
|
|
expect(unified?.accounts.work.continuity_mode).toBe('standard');
|
|
expect(unified?.accounts.broken.continuity_mode).toBe('standard');
|
|
});
|
|
|
|
it('resolves only direct managed backup directories under ~/.ccs', () => {
|
|
const managedBackupPath = path.join(ccsDir, 'backup-v1-2026-03-24');
|
|
const externalBackupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-backup-external-'));
|
|
const symlinkedBackupPath = path.join(ccsDir, 'backup-v1-symlink');
|
|
|
|
fs.mkdirSync(managedBackupPath, { recursive: true });
|
|
fs.symlinkSync(externalBackupPath, symlinkedBackupPath, 'dir');
|
|
|
|
expect(resolveManagedBackupPath(managedBackupPath)).toBe(
|
|
fs.realpathSync.native(managedBackupPath)
|
|
);
|
|
expect(resolveManagedBackupPath(externalBackupPath)).toBeNull();
|
|
expect(resolveManagedBackupPath(symlinkedBackupPath)).toBeNull();
|
|
|
|
fs.rmSync(externalBackupPath, { recursive: true, force: true });
|
|
});
|
|
|
|
it('rejects rollback from directories outside managed CCS backups', async () => {
|
|
const externalBackupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-backup-invalid-'));
|
|
|
|
expect(await rollback(externalBackupPath)).toBe(false);
|
|
|
|
fs.rmSync(externalBackupPath, { recursive: true, force: true });
|
|
});
|
|
|
|
it('restores files only from managed CCS backup directories', async () => {
|
|
const backupPath = path.join(ccsDir, 'backup-v1-2026-03-24');
|
|
fs.mkdirSync(backupPath, { recursive: true });
|
|
fs.writeFileSync(path.join(backupPath, 'config.json'), JSON.stringify({ restored: true }));
|
|
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 8\n');
|
|
fs.mkdirSync(path.join(ccsDir, 'cache'), { recursive: true });
|
|
fs.writeFileSync(path.join(ccsDir, 'cache', 'usage.json'), '{}');
|
|
|
|
const success = await rollback(backupPath);
|
|
|
|
expect(success).toBe(true);
|
|
expect(fs.existsSync(path.join(ccsDir, 'config.yaml'))).toBe(false);
|
|
expect(JSON.parse(fs.readFileSync(path.join(ccsDir, 'config.json'), 'utf8'))).toEqual({
|
|
restored: true,
|
|
});
|
|
expect(fs.existsSync(path.join(ccsDir, 'usage-cache.json'))).toBe(true);
|
|
});
|
|
});
|