272 lines
9.6 KiB
TypeScript
272 lines
9.6 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdir, mkdtemp, readFile, readdir, 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 {
|
|
createCodingConversationStore,
|
|
validateSessionKey,
|
|
} from '../../electron/coding-projects/conversation-store';
|
|
import { atomicWriteJson } from '../../electron/coding-projects/atomic-json';
|
|
import {
|
|
createCodingProjectAgent,
|
|
createCodingProjectConfigV2,
|
|
normalizeProductModelRef,
|
|
readCodingProjectConfigV2,
|
|
} from '../../electron/coding-projects/project-config';
|
|
import {
|
|
createCodingProjectStore,
|
|
createLocalCodingProject,
|
|
createMemoryCodingProjectStorage,
|
|
} from '../../electron/coding-projects/project-store';
|
|
|
|
vi.mock('node:child_process', () => ({ spawn: vi.fn() }));
|
|
|
|
const scratchRoots: string[] = [];
|
|
const NOW = '2026-08-22T08:00:00.000Z';
|
|
const NEXT = '2026-08-22T08:00:01.000Z';
|
|
const MODEL = {
|
|
accountId: 'account-local',
|
|
modelId: 'provider/model-a',
|
|
thinkingLevel: 'medium' as const,
|
|
};
|
|
const RESPONSIBILITY = {
|
|
mission: 'Implement the assigned work',
|
|
owns: ['electron/coding-projects'],
|
|
boundaries: [],
|
|
collaborators: [],
|
|
principles: ['Keep metadata local'],
|
|
};
|
|
|
|
async function makeProjectPath(): Promise<string> {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-coding-v2-'));
|
|
scratchRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.clearAllMocks();
|
|
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
|
|
recursive: true,
|
|
force: true,
|
|
})));
|
|
});
|
|
|
|
describe('coding project schema v2', () => {
|
|
it('accepts the native max thinking level in a project model reference', () => {
|
|
expect(normalizeProductModelRef({
|
|
accountId: 'account-local',
|
|
modelId: 'deepseek-v4-pro',
|
|
thinkingLevel: 'max',
|
|
})).toEqual({
|
|
accountId: 'account-local',
|
|
modelId: 'deepseek-v4-pro',
|
|
thinkingLevel: 'max',
|
|
});
|
|
});
|
|
|
|
it('creates project, Agent, and empty Conversation metadata without a runtime child', async () => {
|
|
const projectPath = await makeProjectPath();
|
|
const storage = createMemoryCodingProjectStorage();
|
|
const store = createCodingProjectStore(storage, {
|
|
createId: () => 'project-stable-id',
|
|
now: () => NOW,
|
|
});
|
|
|
|
const projectStartedAt = performance.now();
|
|
const { project, config } = await createLocalCodingProject({ projectPath, now: NOW }, store);
|
|
const projectDurationMs = performance.now() - projectStartedAt;
|
|
|
|
const agentStartedAt = performance.now();
|
|
const agent = await createCodingProjectAgent(projectPath, {
|
|
id: 'implementer',
|
|
avatarId: 'avatar-01',
|
|
roleName: 'Implementer',
|
|
name: 'Implementation Agent',
|
|
model: MODEL,
|
|
modelResolution: 'resolved',
|
|
responsibility: RESPONSIBILITY,
|
|
prompt: '\nPreserve this prompt.\n',
|
|
skillIds: ['tdd'],
|
|
}, { now: NEXT });
|
|
const agentDurationMs = performance.now() - agentStartedAt;
|
|
|
|
const conversations = createCodingConversationStore(projectPath, {
|
|
createId: () => 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
now: () => NEXT,
|
|
});
|
|
const conversationStartedAt = performance.now();
|
|
const conversation = await conversations.create({
|
|
agentId: agent.id,
|
|
title: 'Local draft',
|
|
model: MODEL,
|
|
modelResolution: 'resolved',
|
|
});
|
|
const conversationDurationMs = performance.now() - conversationStartedAt;
|
|
|
|
expect(project.id).toBe('project-stable-id');
|
|
expect(config).toMatchObject({ schemaVersion: 2, agents: [] });
|
|
expect(agent).toMatchObject({
|
|
id: 'implementer',
|
|
name: 'Implementation Agent',
|
|
prompt: '\nPreserve this prompt.\n',
|
|
skillIds: ['tdd'],
|
|
archivedAt: null,
|
|
model: MODEL,
|
|
modelResolution: 'resolved',
|
|
});
|
|
expect(conversation).toMatchObject({
|
|
id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
agentId: 'implementer',
|
|
model: MODEL,
|
|
modelResolution: 'resolved',
|
|
});
|
|
expect(conversation).not.toHaveProperty('piSessionId');
|
|
expect(conversation).not.toHaveProperty('sessionKey');
|
|
expect(vi.mocked(spawn)).not.toHaveBeenCalled();
|
|
expect(projectDurationMs).toBeLessThan(1_000);
|
|
expect(agentDurationMs).toBeLessThan(500);
|
|
expect(conversationDurationMs).toBeLessThan(500);
|
|
});
|
|
|
|
it('uses .makelore as the only project metadata namespace', async () => {
|
|
const projectPath = await makeProjectPath();
|
|
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
|
createId: () => 'project-makelore-only',
|
|
now: () => NOW,
|
|
});
|
|
await createLocalCodingProject({ projectPath, now: NOW }, store);
|
|
|
|
expect(JSON.parse(await readFile(
|
|
path.join(projectPath, '.makelore', 'project.json'),
|
|
'utf8',
|
|
))).toMatchObject({ schemaVersion: 2 });
|
|
await expect(readFile(
|
|
path.join(projectPath, '.niancode', 'project.json'),
|
|
'utf8',
|
|
)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
|
|
const oldNamespaceOnly = await makeProjectPath();
|
|
await mkdir(path.join(oldNamespaceOnly, '.niancode'), { recursive: true });
|
|
await writeFile(
|
|
path.join(oldNamespaceOnly, '.niancode', 'project.json'),
|
|
JSON.stringify(createCodingProjectConfigV2(NOW)),
|
|
'utf8',
|
|
);
|
|
await expect(readCodingProjectConfigV2(oldNamespaceOnly)).resolves.toEqual({ status: 'missing' });
|
|
});
|
|
|
|
it('requires an explicit model selection when resolution is required', async () => {
|
|
const projectPath = await makeProjectPath();
|
|
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
|
createId: () => 'project-id',
|
|
now: () => NOW,
|
|
});
|
|
await createLocalCodingProject({ projectPath, now: NOW }, store);
|
|
|
|
const agent = await createCodingProjectAgent(projectPath, {
|
|
id: 'unresolved',
|
|
avatarId: 'avatar-02',
|
|
roleName: 'Unresolved',
|
|
name: 'Unresolved Agent',
|
|
model: null,
|
|
modelResolution: 'required',
|
|
responsibility: RESPONSIBILITY,
|
|
}, { now: NEXT });
|
|
|
|
expect(agent).toMatchObject({ model: null, modelResolution: 'required' });
|
|
const read = await readCodingProjectConfigV2(projectPath);
|
|
expect(read.status).toBe('valid');
|
|
if (read.status === 'valid') {
|
|
expect(read.config.agents[0]).toMatchObject({ model: null, modelResolution: 'required' });
|
|
}
|
|
});
|
|
|
|
it('preserves one project id across concurrent first opens of the same folder', async () => {
|
|
const projectPath = await makeProjectPath();
|
|
let createCount = 0;
|
|
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
|
createId: () => `project-${++createCount}`,
|
|
now: () => NOW,
|
|
});
|
|
|
|
const [first, second] = await Promise.all([
|
|
store.openFolder(projectPath),
|
|
store.openFolder(projectPath),
|
|
]);
|
|
|
|
expect(first.id).toBe('project-1');
|
|
expect(second.id).toBe('project-1');
|
|
expect(createCount).toBe(1);
|
|
expect(await store.listProjects()).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('coding Conversation schema v2', () => {
|
|
it.each([
|
|
'../session',
|
|
'..\\session',
|
|
'/absolute/session',
|
|
'C:\\absolute\\session',
|
|
'nested/session',
|
|
'nested\\session',
|
|
' session-opaque ',
|
|
])('rejects a non-opaque session key: %s', (sessionKey) => {
|
|
expect(() => validateSessionKey(sessionKey)).toThrow('opaque relative key');
|
|
});
|
|
|
|
it('allows only one Pi session creation for concurrent first prompts', async () => {
|
|
const projectPath = await makeProjectPath();
|
|
const store = createCodingConversationStore(projectPath, {
|
|
createId: () => '2c1f4e52-4af8-4fce-a82e-18972ed71b47',
|
|
now: () => NOW,
|
|
});
|
|
const conversation = await store.create({
|
|
agentId: 'implementer',
|
|
title: 'First prompt',
|
|
model: MODEL,
|
|
modelResolution: 'resolved',
|
|
});
|
|
let release!: () => void;
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const createBinding = vi.fn(async () => {
|
|
await gate;
|
|
return { piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' };
|
|
});
|
|
|
|
const first = store.ensureSessionBinding(conversation.id, createBinding);
|
|
const second = store.ensureSessionBinding(conversation.id, createBinding);
|
|
await vi.waitFor(() => expect(createBinding).toHaveBeenCalledTimes(1));
|
|
release();
|
|
|
|
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
expect.objectContaining({ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }),
|
|
expect.objectContaining({ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }),
|
|
]);
|
|
expect(createBinding).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(await readFile(path.join(projectPath, '.makelore', 'conversations.json'), 'utf8')))
|
|
.toMatchObject({
|
|
schemaVersion: 2,
|
|
conversations: [{ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }],
|
|
});
|
|
});
|
|
|
|
it('atomically replaces JSON and cleans its temporary file after a failed replace', async () => {
|
|
const projectPath = await makeProjectPath();
|
|
const metadataDirectory = path.join(projectPath, '.makelore');
|
|
const filePath = path.join(metadataDirectory, 'atomic.json');
|
|
await atomicWriteJson(filePath, { version: 1 });
|
|
await atomicWriteJson(filePath, { version: 2 });
|
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ version: 2 });
|
|
|
|
const directoryTarget = path.join(metadataDirectory, 'cannot-replace-directory');
|
|
await mkdir(directoryTarget);
|
|
await expect(atomicWriteJson(directoryTarget, { version: 3 })).rejects.toBeDefined();
|
|
expect((await readdir(metadataDirectory)).filter((name) => name.endsWith('.tmp'))).toEqual([]);
|
|
});
|
|
});
|