Files
makelore/tests/unit/coding-projects-migration.test.ts
brother7 7a15b1b49c fix: close PI-140 cutover gaps
Back up unknown legacy Agents, reconnect settled Conversation observation, remove remaining active repository residue, and restore the bundled Python verifier import.
2026-08-24 12:47:16 +08:00

301 lines
11 KiB
TypeScript

// @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 {
buildLegacyProjectAgentManifest,
normalizeLegacyProjectConfigV1,
} from '../../electron/coding-projects/legacy-v1';
import { atomicWriteJson } from '../../electron/coding-projects/atomic-json';
import {
acknowledgeLegacyConversationNotice,
createCodingProjectConfigV2,
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 buildLegacyProjectAgentManifest(normalizeLegacyProjectConfigV1(config))) {
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('leaves legacy directories inert once project v2 is valid', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-current-v2-'));
scratchRoots.push(projectPath);
const projectFile = path.join(projectPath, '.niancode', 'project.json');
const legacyFile = path.join(projectPath, '.opencode', 'agent', 'legacy.md');
await Promise.all([
mkdir(path.dirname(projectFile), { recursive: true }),
mkdir(path.dirname(legacyFile), { recursive: true }),
]);
await writeFile(projectFile, JSON.stringify(
createCodingProjectConfigV2(CREATED, 'custom'),
null,
2,
));
await writeFile(legacyFile, 'must remain untouched\n', 'utf8');
const resolveLegacyModel = vi.fn(async () => MODEL);
const copy = vi.fn(copyFile);
const result = await migrateCodingProjectToV2(projectPath, {
resolveLegacyModel,
copyFile: copy,
});
expect(result.status).toBe('already-current');
expect(resolveLegacyModel).not.toHaveBeenCalled();
expect(copy).not.toHaveBeenCalled();
expect(await readFile(legacyFile, 'utf8')).toBe('must remain untouched\n');
});
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).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();
const customFile = path.join(staged.projectPath, '.opencode', 'agent', 'custom.md');
await writeFile(customFile, 'unknown Agent\n', 'utf8');
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 buildLegacyProjectAgentManifest(normalizeLegacyProjectConfigV1(staged.config))) {
expect(await readFile(path.join(staged.projectPath, '.opencode', entry.relativePath), 'utf8'))
.toBe(entry.content);
}
expect(await readFile(customFile, 'utf8')).toBe('unknown Agent\n');
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: [] });
expect(retry.backedUpUncertainAgents).toEqual(['custom.md']);
await expect(readFile(customFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
expect(await readFile(
path.join(retry.backupDirectory, '.opencode', 'agent', 'custom.md'),
'utf8',
)).toBe('unknown Agent\n');
});
});