feat: add coding project schema v2 migration
This commit is contained in:
261
tests/unit/coding-projects-migration.test.ts
Normal file
261
tests/unit/coding-projects-migration.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
// @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>): 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: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user