// @vitest-environment node import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ProjectAgentConfig, ProjectConfig } from '../../shared/project-config'; import { buildProjectAgentManifest, normalizeProjectConfig, } from '../../electron/opencode/project-config'; import { atomicWriteJson } from '../../electron/coding-projects/atomic-json'; import { acknowledgeLegacyConversationNotice, readCodingProjectConfigV2, } from '../../electron/coding-projects/project-config'; import { migrateCodingProjectToV2 } from '../../electron/coding-projects/migration'; const scratchRoots: string[] = []; const CREATED = '2026-08-20T00:00:00.000Z'; const MIGRATED = '2026-08-22T09:00:00.000Z'; const RETRIED = '2026-08-22T09:01:00.000Z'; const MODEL = { accountId: 'account-a', modelId: 'provider/model-a', thinkingLevel: 'high' as const, }; function makeAgent(overrides: Partial): ProjectAgentConfig { return { id: 'agent-a', avatarId: 'avatar-01', roleName: 'Implementer', name: 'Agent A', builtIn: false, enabled: true, model: 'legacy/model-a', skillIds: ['tdd', 'game-engine'], responsibility: { mission: 'Implement', owns: ['electron/coding-projects'], boundaries: [], collaborators: [], principles: ['Preserve state'], }, prompt: '\nOriginal prompt\n', archivedAt: null, pinned: true, createdAt: CREATED, updatedAt: CREATED, ...overrides, }; } function makeLegacyConfig(): ProjectConfig { return { schemaVersion: 1, projectType: 'custom', initialized: true, defaultModel: null, agents: [ makeAgent({ id: 'unique', name: 'Unique', model: 'legacy/unique' }), makeAgent({ id: 'unresolved', avatarId: 'avatar-02', name: 'Unresolved', model: 'legacy/ambiguous', prompt: 'Keep unresolved prompt', archivedAt: '2026-08-21T00:00:00.000Z', }), makeAgent({ id: 'no-account', avatarId: 'avatar-03', name: 'No Account', model: null, skillIds: [], }), ], knowledgeDirectory: 'knowledge', createdAt: CREATED, updatedAt: CREATED, }; } async function stageLegacyProject(options: { conversations?: boolean } = {}): Promise<{ projectPath: string; config: ProjectConfig; projectSource: string; conversationSource: string | null; }> { const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-migration-v2-')); scratchRoots.push(projectPath); const config = makeLegacyConfig(); const projectFile = path.join(projectPath, '.niancode', 'project.json'); const agentDirectory = path.join(projectPath, '.opencode', 'agent'); await Promise.all([ mkdir(path.dirname(projectFile), { recursive: true }), mkdir(agentDirectory, { recursive: true }), mkdir(path.join(projectPath, '.opencode', 'skills'), { recursive: true }), ]); const projectSource = `${JSON.stringify(config, null, 2)}\n`; await writeFile(projectFile, projectSource, 'utf8'); for (const entry of buildProjectAgentManifest(normalizeProjectConfig(config)).entries) { await writeFile(path.join(projectPath, '.opencode', entry.relativePath), entry.content, 'utf8'); } await writeFile(path.join(projectPath, '.opencode', 'skills', 'keep.md'), 'keep me', 'utf8'); let conversationSource: string | null = null; if (options.conversations !== false) { conversationSource = `${JSON.stringify({ schemaVersion: 1, sessions: [{ id: 'old-opencode-session', agentId: 'unique', title: 'Legacy' }], }, null, 2)}\n`; await writeFile(path.join(projectPath, '.niancode', 'conversations.json'), conversationSource, 'utf8'); } return { projectPath, config, projectSource, conversationSource }; } afterEach(async () => { await Promise.all(scratchRoots.splice(0).map((root) => rm(root, { recursive: true, force: true, }))); }); describe('coding project v1 to v2 migration', () => { it('backs up v1, maps unique models, requires unresolved selection, and hides old sessions', async () => { const staged = await stageLegacyProject(); const unresolvedFile = path.join(staged.projectPath, '.opencode', 'agent', 'unresolved.md'); const customFile = path.join(staged.projectPath, '.opencode', 'agent', 'custom.md'); await writeFile(unresolvedFile, 'locally modified Agent\n', 'utf8'); await writeFile(customFile, 'unknown Agent\n', 'utf8'); const resolveLegacyModel = vi.fn(async ({ legacyModel }: { legacyModel: string }) => ( legacyModel === 'legacy/unique' ? MODEL : null )); const result = await migrateCodingProjectToV2(staged.projectPath, { resolveLegacyModel, now: () => MIGRATED, }); expect(result.status).toBe('migrated'); if (result.status !== 'migrated') throw new Error('Expected migrated result'); expect(result.config.agents).toEqual([ expect.objectContaining({ id: 'unique', name: 'Unique', prompt: '\nOriginal prompt\n', skillIds: ['tdd', 'game-engine'], archivedAt: null, model: MODEL, modelResolution: 'resolved', }), expect.objectContaining({ id: 'unresolved', prompt: 'Keep unresolved prompt', archivedAt: '2026-08-21T00:00:00.000Z', model: null, modelResolution: 'required', }), expect.objectContaining({ id: 'no-account', model: null, modelResolution: 'required', }), ]); expect(resolveLegacyModel).toHaveBeenCalledTimes(2); expect(result.removedGeneratedAgents.sort()).toEqual(['no-account.md', 'unique.md']); expect(result.backedUpUncertainAgents.sort()).toEqual(['custom.md', 'unresolved.md']); expect(JSON.parse(await readFile( path.join(staged.projectPath, '.niancode', 'conversations.json'), 'utf8', ))).toEqual({ schemaVersion: 2, conversations: [] }); expect(JSON.parse(await readFile(path.join(result.backupDirectory, 'project.json'), 'utf8'))) .toMatchObject({ schemaVersion: 1 }); expect(await readFile(path.join(result.backupDirectory, 'conversations.json'), 'utf8')) .toBe(staged.conversationSource); expect(await readFile( path.join(result.backupDirectory, '.opencode', 'agent', 'unresolved.md'), 'utf8', )).toBe('locally modified Agent\n'); expect(await readFile( path.join(result.backupDirectory, '.opencode', 'agent', 'custom.md'), 'utf8', )).toBe('unknown Agent\n'); await expect(readFile(unresolvedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); await expect(readFile(customFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); expect(await readFile(path.join(staged.projectPath, '.opencode', 'skills', 'keep.md'), 'utf8')) .toBe('keep me'); const pending = await readCodingProjectConfigV2(staged.projectPath); expect(pending).toMatchObject({ status: 'valid', config: { legacyConversationNotice: 'pending' } }); const acknowledged = await acknowledgeLegacyConversationNotice(staged.projectPath, { now: RETRIED }); expect(acknowledged.legacyConversationNotice).toBe('acknowledged'); }); it('does not overwrite v1 when the backup cannot be created', async () => { const staged = await stageLegacyProject(); const writer = vi.fn(atomicWriteJson); const blockedCopy = vi.fn(async () => { throw new Error('backup unavailable'); }) as unknown as typeof copyFile; await expect(migrateCodingProjectToV2(staged.projectPath, { resolveLegacyModel: async () => MODEL, now: () => MIGRATED, copyFile: blockedCopy, writeJson: writer, })).rejects.toThrow('backup unavailable'); expect(writer).not.toHaveBeenCalled(); expect(await readFile(path.join(staged.projectPath, '.niancode', 'project.json'), 'utf8')) .toBe(staged.projectSource); expect(await readFile(path.join(staged.projectPath, '.niancode', 'conversations.json'), 'utf8')) .toBe(staged.conversationSource); }); it('restores retryable v1 state after a corrupt metadata write and succeeds on retry', async () => { const staged = await stageLegacyProject(); let writeCount = 0; const corruptingWriter = vi.fn(async (filePath: string, value: unknown) => { writeCount += 1; if (writeCount === 2) { await atomicWriteJson(filePath, { corrupt: true }); throw new Error('simulated project write failure'); } await atomicWriteJson(filePath, value); }); await expect(migrateCodingProjectToV2(staged.projectPath, { resolveLegacyModel: async ({ legacyModel }) => legacyModel === 'legacy/unique' ? MODEL : null, now: () => MIGRATED, writeJson: corruptingWriter, })).rejects.toThrow('simulated project write failure'); expect(await readFile(path.join(staged.projectPath, '.niancode', 'project.json'), 'utf8')) .toBe(staged.projectSource); expect(await readFile(path.join(staged.projectPath, '.niancode', 'conversations.json'), 'utf8')) .toBe(staged.conversationSource); for (const entry of buildProjectAgentManifest(normalizeProjectConfig(staged.config)).entries) { expect(await readFile(path.join(staged.projectPath, '.opencode', entry.relativePath), 'utf8')) .toBe(entry.content); } const retry = await migrateCodingProjectToV2(staged.projectPath, { resolveLegacyModel: async ({ legacyModel }) => legacyModel === 'legacy/unique' ? MODEL : null, now: () => RETRIED, }); expect(retry.status).toBe('migrated'); expect(JSON.parse(await readFile( path.join(staged.projectPath, '.niancode', 'conversations.json'), 'utf8', ))).toEqual({ schemaVersion: 2, conversations: [] }); }); });