Files
makelore/tests/unit/coding-product-services.test.ts

259 lines
10 KiB
TypeScript

// @vitest-environment node
import { execFile } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
import type { AgentBrowserModule } from '../../electron/agent-browser';
import {
CodingProductHostError,
createCodingProductHost,
type CodingProductHostOptions,
} from '../../electron/api/coding-product-services';
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
import {
createCodingProjectAgent,
createCodingProjectMetadata,
} from '../../electron/coding-projects/project-config';
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
const roots: string[] = [];
const conversationId = '11111111-1111-4111-8111-111111111111';
const exec = promisify(execFile);
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function temporaryRoot(prefix: string): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), prefix));
roots.push(root);
return root;
}
async function configuredProject(skillIds: readonly string[] = ['agent-browser', 'grilling']): Promise<string> {
const root = await temporaryRoot('makelore-pi-products-');
await createCodingProjectMetadata(root, { now: '2026-08-23T00:00:00.000Z' });
await createCodingProjectAgent(root, {
id: 'builder',
avatarId: 'avatar-01',
roleName: '实现者',
name: 'Builder',
model: null,
modelResolution: 'required',
skillIds: [...skillIds],
responsibility: {
mission: 'Implement changes', owns: [], boundaries: [], collaborators: [], principles: [],
},
}, { now: '2026-08-23T00:00:00.000Z' });
await createCodingConversationStore(root, {
createId: () => conversationId,
now: () => '2026-08-23T00:00:00.000Z',
}).create({
agentId: 'builder', title: 'PI-105', model: null, modelResolution: 'required',
});
return root;
}
function productTools(root: string, withDataService = false): PiProductTools {
return new PiProductTools({
browser: {} as AgentBrowserModule,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
...(withDataService ? {
pluginSkillSources: [{
id: 'data-service',
pluginId: 'makelore.data-service',
directory: path.resolve('resources/coding-plugins/data-service'),
entryPath: 'skills/data-service/SKILL.md',
}],
} : {}),
});
}
function projectService(
root: string,
active = true,
): CodingProductHostOptions['projects'] {
const project = {
id: 'project-a',
path: root,
name: 'project-a',
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
lastOpenedAt: '2026-08-23T00:00:00.000Z',
};
return {
getActiveProject: async () => active ? project : null,
findActiveConversation: async (id) => {
if (!active) throw new Error('No active project');
const conversation = await createCodingConversationStore(root).get(id);
if (!conversation) {
throw new CodingProductHostError(
404,
'CODING_CONVERSATION_NOT_FOUND',
'Coding Conversation does not exist',
);
}
return { project, conversation };
},
};
}
async function git(root: string, ...args: string[]): Promise<void> {
await exec('git', ['-C', root, ...args], { windowsHide: true });
}
async function gitOutput(root: string, ...args: string[]): Promise<string> {
return (await exec('git', ['-C', root, ...args], { windowsHide: true })).stdout.trim();
}
describe('PI-105 product Host composition', () => {
it('projects managed skills and a stable command catalog without raw Pi fields', async () => {
const root = await configuredProject();
const tools = productTools(root);
const host = createCodingProductHost({
projects: projectService(root),
productTools: tools,
listPiCommands: async () => ({
commands: [
{ name: 'custom', description: 'Custom Pi command', token: 'pi-secret' },
{ name: 'compact', description: 'Must not shadow Makelore' },
{ name: '../unsafe', description: 'Invalid command' },
],
apiKey: 'raw-provider-secret',
}),
});
const skills = await host.listSkills('builder');
expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual([
'agent-browser', 'grilling',
]);
const commands = await host.listCommands(conversationId);
expect(commands.slice(0, 5).map(({ source }) => source)).toEqual([
'makelore', 'makelore', 'makelore', 'makelore', 'makelore',
]);
expect(commands).toContainEqual(expect.objectContaining({ name: 'custom', source: 'pi' }));
expect(commands).toContainEqual(expect.objectContaining({ name: 'agent-browser', source: 'skill' }));
expect(commands.filter(({ name }) => name === 'compact')).toHaveLength(1);
const serialized = JSON.stringify({ skills, commands });
expect(serialized).not.toContain(root);
expect(serialized).not.toContain('pi-secret');
expect(serialized).not.toContain('raw-provider-secret');
expect(serialized).not.toMatch(/"(todo|share|revert|unrevert)"/);
});
it('retains a disabled assigned plugin Skill but only makes it effective after enable', async () => {
const root = await configuredProject(['data-service']);
const tools = productTools(root, true);
let enabled = false;
const host = createCodingProductHost({
projects: projectService(root),
productTools: tools,
getEnabledPluginIds: async () => enabled ? ['makelore.data-service'] : [],
});
await expect(host.listSkills()).resolves.not.toContainEqual(
expect.objectContaining({ id: 'data-service' }),
);
await expect(host.listSkills('builder')).resolves.toContainEqual(
expect.objectContaining({
id: 'data-service', selected: true, available: false, effective: false,
}),
);
await expect(host.listCommands(conversationId)).resolves.not.toContainEqual(
expect.objectContaining({ skillId: 'data-service' }),
);
enabled = true;
await expect(host.listSkills('builder')).resolves.toContainEqual(
expect.objectContaining({
id: 'data-service', selected: true, available: true, effective: true,
}),
);
await expect(host.listCommands(conversationId)).resolves.toContainEqual(
expect.objectContaining({ skillId: 'data-service' }),
);
});
it('reads exact-run changes from the same PiProductTools tracker instance', async () => {
const root = await configuredProject();
await writeFile(path.join(root, 'notes.txt'), 'baseline\n', 'utf8');
const tools = productTools(root);
const host = createCodingProductHost({
projects: projectService(root),
productTools: tools,
});
await tools.beginRun({ conversationId, runId: 'run-a', projectPath: root });
await writeFile(path.join(root, 'notes.txt'), 'changed\n', 'utf8');
await tools.recordTouchedPaths(conversationId, 'run-a', ['notes.txt']);
expect(await host.getChanges(conversationId)).toMatchObject({
conversationId, runId: 'run-a', git: false,
files: [{ path: 'notes.txt', status: 'modified', preview: 'changed\n' }],
});
expect(JSON.stringify(await host.getChanges(conversationId))).not.toContain(root);
});
it('keeps a merge conflict created during the target run in its changes snapshot', async () => {
const root = await configuredProject();
await git(root, 'init');
await git(root, 'config', 'user.email', 'pi-products@example.invalid');
await git(root, 'config', 'user.name', 'PI Products');
await writeFile(path.join(root, 'conflict.txt'), 'baseline\n', 'utf8');
await git(root, 'add', '.');
await git(root, 'commit', '-m', 'baseline');
const baseBranch = await gitOutput(root, 'branch', '--show-current');
await git(root, 'switch', '-c', 'conflicting-change');
await writeFile(path.join(root, 'conflict.txt'), 'branch change\n', 'utf8');
await git(root, 'commit', '-am', 'branch change');
await git(root, 'switch', baseBranch);
await writeFile(path.join(root, 'conflict.txt'), 'base change\n', 'utf8');
await git(root, 'commit', '-am', 'base change');
const tools = productTools(root);
const host = createCodingProductHost({
projects: projectService(root),
productTools: tools,
});
await tools.beginRun({ conversationId, runId: 'run-conflict', projectPath: root });
await expect(exec('git', ['-C', root, 'merge', 'conflicting-change'], { windowsHide: true }))
.rejects.toThrow();
await tools.markBash(conversationId, 'run-conflict');
await tools.settleRun(conversationId, 'run-conflict');
expect(await host.getChanges(conversationId)).toMatchObject({
conversationId,
runId: 'run-conflict',
files: [expect.objectContaining({ path: 'conflict.txt', status: 'conflicted' })],
});
});
it('returns typed project, Agent, and Conversation errors', async () => {
const root = await configuredProject();
const tools = productTools(root);
const unavailable = createCodingProductHost({
projects: projectService(root, false),
productTools: tools,
});
await expect(unavailable.fileStatus()).rejects.toMatchObject<CodingProductHostError>({
status: 409, code: 'CODING_ACTIVE_PROJECT_REQUIRED',
});
const host = createCodingProductHost({
projects: projectService(root),
productTools: tools,
});
await expect(host.listSkills('missing')).rejects.toMatchObject<CodingProductHostError>({
status: 404, code: 'CODING_AGENT_NOT_FOUND',
});
await expect(host.listCommands('22222222-2222-4222-8222-222222222222'))
.rejects.toMatchObject<CodingProductHostError>({
status: 404, code: 'CODING_CONVERSATION_NOT_FOUND',
});
});
});