import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { mkdir, mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { detectMigration, runMigration } from '../src/index.js'; import { computeWorkdirBucket, oldMd5BucketName } from '../src/sessions/workdir-bucket.js'; const FIXTURES = fileURLToPath(new URL('./fixtures', import.meta.url)); const SOURCE_HOME = join(FIXTURES, 'multi-workdir', '.kimi'); const MARKER_PATH = join(SOURCE_HOME, '.migrated-to-kimi-code'); const FIXTURE_CONFIG = join(SOURCE_HOME, 'config.toml'); let tgt: string; beforeEach(async () => { tgt = await mkdtemp(join(tmpdir(), 'integration-')); // Clean any leftover artifacts that previous failed runs might have left in // the committed fixture directory. await rm(MARKER_PATH, { recursive: true, force: true }); await rm(FIXTURE_CONFIG, { force: true }); }); afterEach(async () => { await rm(tgt, { recursive: true, force: true }); await rm(MARKER_PATH, { recursive: true, force: true }); await rm(FIXTURE_CONFIG, { force: true }); }); describe('runMigration (end-to-end on multi-workdir fixture)', () => { it('migrates everything when scope is full and limit is null', async () => { const plan = await detectMigration({ sourcePath: SOURCE_HOME }); const report = await runMigration({ plan, scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: true, }, source: SOURCE_HOME, target: tgt, }); expect(report.summary.sessions.sessionsMigrated).toBeGreaterThan(0); const indexText = await readFile(join(tgt, 'session_index.jsonl'), 'utf-8'); const indexLines = indexText.split('\n').filter((l) => l.length > 0); expect(indexLines.length).toBeGreaterThan(0); const markerText = await readFile(MARKER_PATH, 'utf-8'); const marker: unknown = JSON.parse(markerText); expect((marker as { version: number }).version).toBe(1); }); it('completes the migration even when the source marker cannot be written', async () => { // Block the marker path with a directory so writeMarker()'s writeFile fails. await mkdir(MARKER_PATH, { recursive: true }); const plan = await detectMigration({ sourcePath: SOURCE_HOME }); const report = await runMigration({ plan, scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: true }, source: SOURCE_HOME, target: tgt, }); // All data migrated — a completed run must return a report, not reject. expect(report.summary.sessions.sessionsMigrated).toBeGreaterThan(0); }); it('does not write a completed marker when legacy session data remains unreadable', async () => { const src = await mkdtemp(join(tmpdir(), 'failed-session-marker-src-')); try { const bucket = join(src, 'sessions', '11111111111111111111111111111111'); await mkdir(join(bucket, 'legacy-session'), { recursive: true }); const plan = await detectMigration({ sourcePath: src }); const report = await runMigration({ plan, scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: true }, source: src, target: tgt, }); expect(report.summary.sessions.sessionsFailed).toHaveLength(1); await expect(readFile(join(src, '.migrated-to-kimi-code'), 'utf-8')).rejects.toThrow(); } finally { await rm(src, { recursive: true, force: true }); } }); it('config-only scope writes config but skips sessions', async () => { // Materialize a config.toml in the fixture; afterEach cleans it up. await writeFile(FIXTURE_CONFIG, 'default_thinking = true\n'); const plan = await detectMigration({ sourcePath: SOURCE_HOME }); const report = await runMigration({ plan, scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: false, }, source: SOURCE_HOME, target: tgt, }); expect(report.summary.sessions.scope).toBe('config-only'); expect(report.summary.sessions.sessionsMigrated).toBe(0); }); it('migrates user skills bundles end-to-end and surfaces them in the report', async () => { // Drive the full pipeline against a synthetic source so the assertion // tests integration (run-migration wiring + paths + summary plumbing), // not just the step in isolation. const src = await mkdtemp(join(tmpdir(), 'skills-e2e-src-')); try { await mkdir(join(src, 'skills', 'my-bundle'), { recursive: true }); await writeFile( join(src, 'skills', 'my-bundle', 'SKILL.md'), '---\nname: my-bundle\ndescription: e2e\n---\n', ); await writeFile(join(src, 'skills', 'flat.md'), '---\nname: flat\ndescription: e2e\n---\n'); const plan = await detectMigration({ sourcePath: src }); const report = await runMigration({ plan, scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: false }, source: src, target: tgt, }); expect(report.summary.skills.copied).toBe(2); expect(report.summary.skills.skippedExisting).toBe(0); await expect( readFile(join(tgt, 'skills', 'my-bundle', 'SKILL.md'), 'utf-8'), ).resolves.toContain('my-bundle'); await expect(readFile(join(tgt, 'skills', 'flat.md'), 'utf-8')).resolves.toContain('flat'); } finally { await rm(src, { recursive: true, force: true }); } }); it('skips skills migration when scope.skills is false', async () => { const src = await mkdtemp(join(tmpdir(), 'skills-off-src-')); try { await mkdir(join(src, 'skills', 'mine'), { recursive: true }); await writeFile(join(src, 'skills', 'mine', 'SKILL.md'), 'x'); const plan = await detectMigration({ sourcePath: src }); const report = await runMigration({ plan, scope: { config: true, mcp: true, userHistory: true, skills: false, sessions: false }, source: src, target: tgt, }); expect(report.summary.skills).toEqual({ copied: 0, skippedExisting: 0 }); await expect(readFile(join(tgt, 'skills', 'mine', 'SKILL.md'))).rejects.toThrow(); } finally { await rm(src, { recursive: true, force: true }); } }); it('does not write a completed marker when the legacy config cannot be parsed', async () => { const src = await mkdtemp(join(tmpdir(), 'bad-config-marker-src-')); try { await writeFile(join(src, 'config.toml'), 'this is = = not valid toml [[['); const plan = await detectMigration({ sourcePath: src }); expect(plan.hasConfig).toBe(true); const report = await runMigration({ plan, scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: true }, source: src, target: tgt, }); expect(report.summary.config.sourceUnreadable).toBe(true); await expect(readFile(join(src, '.migrated-to-kimi-code'), 'utf-8')).rejects.toThrow(); } finally { await rm(src, { recursive: true, force: true }); } }); it('does not write a completed marker when the legacy mcp.json cannot be parsed', async () => { const src = await mkdtemp(join(tmpdir(), 'bad-mcp-marker-src-')); try { await writeFile(join(src, 'mcp.json'), 'not json {{{'); const plan = await detectMigration({ sourcePath: src }); const report = await runMigration({ plan, scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: true }, source: src, target: tgt, }); expect(report.summary.mcp.sourceUnreadable).toBe(true); await expect(readFile(join(src, '.migrated-to-kimi-code'), 'utf-8')).rejects.toThrow(); } finally { await rm(src, { recursive: true, force: true }); } }); it('does not write a completed marker when a session target is occupied by a foreign session', async () => { const src = await mkdtemp(join(tmpdir(), 'conflict-marker-src-')); try { const workdir = '/workspace/conflict-proj'; const uuid = 'conflict-session'; const bucket = join(src, 'sessions', oldMd5BucketName(workdir)); await mkdir(join(bucket, uuid), { recursive: true }); await writeFile(join(src, 'kimi.json'), JSON.stringify({ work_dirs: [{ path: workdir }] })); await writeFile(join(bucket, uuid, 'context.jsonl'), '{"role":"user","content":"hi"}\n'); const foreignDir = join( tgt, 'sessions', computeWorkdirBucket(workdir), `ses_${uuid}`, ); await mkdir(foreignDir, { recursive: true }); await writeFile(join(foreignDir, 'state.json'), '{}'); const plan = await detectMigration({ sourcePath: src }); const report = await runMigration({ plan, scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: true }, source: src, target: tgt, }); expect(report.summary.sessions.sessionsConflicts).toHaveLength(1); await expect(readFile(join(src, '.migrated-to-kimi-code'), 'utf-8')).rejects.toThrow(); } finally { await rm(src, { recursive: true, force: true }); } }); it('does not copy OAuth credentials into the target', async () => { // OAuth refresh tokens rotate server-side: they are single-use and // single-owner. Copying a credential to a second install breaks login // for whichever side refreshes second. The migration must NOT copy // credentials — it leaves the legacy login alone and asks the user to // run /login in kimi-code instead. const src = await mkdtemp(join(tmpdir(), 'oauth-src-')); try { await mkdir(join(src, 'credentials'), { recursive: true }); await writeFile( join(src, 'credentials', 'kimi-code.json'), JSON.stringify({ access_token: 'a', refresh_token: 'r', expires_at: 1, scope: 's', token_type: 'Bearer', }), ); const plan = await detectMigration({ sourcePath: src }); const report = await runMigration({ plan, scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: false }, source: src, target: tgt, }); // The credential must not be copied into the target. await expect( readFile(join(tgt, 'credentials', 'kimi-code.json'), 'utf-8'), ).rejects.toThrow(); // The report tells the user to sign in again in kimi-code. expect(report.notices.oauthLoginsRequiringRelogin).toContain('kimi-code'); } finally { await rm(src, { recursive: true, force: true }); } }); });