feat(coding): add PI-105 product file host API
This commit is contained in:
41
tests/electron-runtime/coding-files-host.test.ts
Normal file
41
tests/electron-runtime/coding-files-host.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { CodingProductHost } from '../../electron/api/coding-product-services';
|
||||
import type { HostApiContext } from '../../electron/api/context';
|
||||
import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher';
|
||||
import { CodingProjectFileService } from '../../electron/coding-projects/project-files';
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('PI-105 Electron Host API seam', () => {
|
||||
it('reads a project-relative file through the Main dispatcher without loopback or path leakage', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-host-dispatch-'));
|
||||
roots.push(root);
|
||||
await writeFile(path.join(root, 'notes.txt'), 'Main-owned content\n', 'utf8');
|
||||
const files = new CodingProjectFileService();
|
||||
const host = {
|
||||
fileContent: async (filePath: string) => await files.content(root, filePath),
|
||||
} as CodingProductHost;
|
||||
const ctx = {
|
||||
codingProducts: { host, attachments: {}, productTools: {} },
|
||||
} as HostApiContext;
|
||||
|
||||
const response = await dispatchHostApiRequest(ctx, {
|
||||
path: '/api/coding/files/content?path=notes.txt',
|
||||
});
|
||||
expect(response).toMatchObject({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
file: { path: 'notes.txt', content: 'Main-owned content\n', truncated: false },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain(root);
|
||||
});
|
||||
});
|
||||
102
tests/unit/coding-files-routes.test.ts
Normal file
102
tests/unit/coding-files-routes.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CodingProductHost } from '../../electron/api/coding-product-services';
|
||||
import type { HostApiContext } from '../../electron/api/context';
|
||||
import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher';
|
||||
|
||||
function context(host?: CodingProductHost): HostApiContext {
|
||||
return (host
|
||||
? { codingProducts: { host, attachments: {}, productTools: {} } }
|
||||
: {}) as HostApiContext;
|
||||
}
|
||||
|
||||
function productHost(): CodingProductHost {
|
||||
return {
|
||||
fileStatus: vi.fn(async () => [{
|
||||
path: 'src/app.ts', name: 'app.ts', type: 'file' as const, status: 'modified' as const,
|
||||
}]),
|
||||
findFiles: vi.fn(async () => [{
|
||||
path: 'src/app.ts', name: 'app.ts', type: 'file' as const, size: 12,
|
||||
}]),
|
||||
fileContent: vi.fn(async () => ({
|
||||
path: 'src/app.ts', content: 'export {};\n', truncated: false,
|
||||
})),
|
||||
searchText: vi.fn(async () => [{
|
||||
path: 'src/app.ts', name: 'app.ts', lineNumber: 1, lineText: 'export {};', submatches: [],
|
||||
}]),
|
||||
listSkills: vi.fn(async () => [{
|
||||
id: 'agent-browser' as const, name: 'Agent Browser', description: 'Browser', selected: true,
|
||||
}]),
|
||||
listCommands: vi.fn(async () => [{
|
||||
name: 'compact', title: '压缩会话', description: 'Compact', source: 'makelore' as const,
|
||||
}]),
|
||||
getChanges: vi.fn(async () => ({
|
||||
conversationId: 'conversation/a', runId: 'run-a', git: false, baselineHead: null, files: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('PI-105 coding product routes', () => {
|
||||
it('dispatches the seven vendor-neutral GET routes with stable DTOs', async () => {
|
||||
const host = productHost();
|
||||
const cases = [
|
||||
['/api/coding/files/status', { files: [expect.objectContaining({ path: 'src/app.ts' })] }],
|
||||
['/api/coding/files/find?query=app&limit=2', { files: [expect.objectContaining({ size: 12 })] }],
|
||||
['/api/coding/files/content?path=src%2Fapp.ts', { file: expect.objectContaining({ content: 'export {};\n' }) }],
|
||||
['/api/coding/search?pattern=export', { matches: [expect.objectContaining({ lineNumber: 1 })] }],
|
||||
['/api/coding/skills?agentId=builder', { skills: [expect.objectContaining({ selected: true })] }],
|
||||
['/api/coding/conversations/conversation%2Fa/commands', {
|
||||
commands: [expect.objectContaining({ name: 'compact', source: 'makelore' })],
|
||||
}],
|
||||
['/api/coding/conversations/conversation%2Fa/changes', {
|
||||
changes: expect.objectContaining({ conversationId: 'conversation/a', runId: 'run-a' }),
|
||||
}],
|
||||
] as const;
|
||||
|
||||
for (const [route, expected] of cases) {
|
||||
const response = await dispatchHostApiRequest(context(host), { path: route });
|
||||
expect(response).toMatchObject({ status: 200, ok: true, json: expected });
|
||||
const serialized = JSON.stringify(response.json);
|
||||
expect(serialized).not.toMatch(/"(todo|share|revert|unrevert)"/);
|
||||
}
|
||||
expect(host.findFiles).toHaveBeenCalledWith('app', 2);
|
||||
expect(host.fileContent).toHaveBeenCalledWith('src/app.ts');
|
||||
expect(host.listSkills).toHaveBeenCalledWith('builder');
|
||||
expect(host.listCommands).toHaveBeenCalledWith('conversation/a');
|
||||
expect(host.getChanges).toHaveBeenCalledWith('conversation/a');
|
||||
});
|
||||
|
||||
it('reports unavailable composition and redacts absolute-path service failures', async () => {
|
||||
expect(await dispatchHostApiRequest(context(), { path: '/api/coding/files/status' }))
|
||||
.toMatchObject({
|
||||
status: 503,
|
||||
json: { success: false, code: 'CODING_PRODUCT_TOOLS_UNAVAILABLE' },
|
||||
});
|
||||
|
||||
const host = productHost();
|
||||
const absolute = path.resolve('private', 'secret.txt');
|
||||
vi.mocked(host.fileContent).mockRejectedValueOnce(
|
||||
Object.assign(new Error(`ENOENT: ${absolute}`), { code: 'ENOENT' }),
|
||||
);
|
||||
const response = await dispatchHostApiRequest(context(host), {
|
||||
path: '/api/coding/files/content?path=missing.txt',
|
||||
});
|
||||
expect(response).toMatchObject({
|
||||
status: 404,
|
||||
json: { success: false, code: 'CODING_FILE_NOT_FOUND', error: 'Project file does not exist' },
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain(absolute);
|
||||
});
|
||||
|
||||
it('does not claim non-GET methods or unrelated coding routes', async () => {
|
||||
expect(await dispatchHostApiRequest(context(productHost()), {
|
||||
path: '/api/coding/files/status', method: 'POST',
|
||||
headers: { 'content-type': 'application/json' }, body: '{}',
|
||||
})).toMatchObject({ status: 404 });
|
||||
expect(await dispatchHostApiRequest(context(productHost()), {
|
||||
path: '/api/coding/conversations/conversation-a/prompt',
|
||||
})).toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
142
tests/unit/coding-product-services.test.ts
Normal file
142
tests/unit/coding-product-services.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { AgentBrowserModule } from '../../electron/agent-browser';
|
||||
import {
|
||||
CodingProductHostError,
|
||||
createCodingProductHost,
|
||||
} 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';
|
||||
|
||||
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(): 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: ['agent-browser', 'grilling'],
|
||||
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): PiProductTools {
|
||||
return new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
getActiveProject: async () => ({ id: 'project-a', path: 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('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({
|
||||
getActiveProject: async () => ({ id: 'project-a', path: 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('returns typed project, Agent, and Conversation errors', async () => {
|
||||
const root = await configuredProject();
|
||||
const tools = productTools(root);
|
||||
const unavailable = createCodingProductHost({
|
||||
getActiveProject: async () => null,
|
||||
productTools: tools,
|
||||
});
|
||||
await expect(unavailable.fileStatus()).rejects.toMatchObject<CodingProductHostError>({
|
||||
status: 409, code: 'CODING_ACTIVE_PROJECT_REQUIRED',
|
||||
});
|
||||
|
||||
const host = createCodingProductHost({
|
||||
getActiveProject: async () => ({ id: 'project-a', path: 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
44
tests/unit/coding-product-tools-facade.test.ts
Normal file
44
tests/unit/coding-product-tools-facade.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hostApiFetchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../src/lib/host-api', () => ({
|
||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
findCodingFiles,
|
||||
getCodingConversationChanges,
|
||||
getCodingConversationCommands,
|
||||
getCodingFileContent,
|
||||
getCodingFileStatus,
|
||||
getCodingSkills,
|
||||
searchCodingText,
|
||||
} from '../../src/lib/coding-product-tools';
|
||||
|
||||
describe('PI-105 Renderer coding product facade', () => {
|
||||
beforeEach(() => {
|
||||
hostApiFetchMock.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('uses only the vendor-neutral coding file and catalog routes', async () => {
|
||||
await getCodingFileStatus();
|
||||
await findCodingFiles('app file', 25);
|
||||
await getCodingFileContent('src/app file.ts');
|
||||
await searchCodingText('hello world');
|
||||
await getCodingSkills('builder/one');
|
||||
await getCodingConversationCommands('conversation/one');
|
||||
await getCodingConversationChanges('conversation/one');
|
||||
|
||||
expect(hostApiFetchMock.mock.calls.map(([route]) => route)).toEqual([
|
||||
'/api/coding/files/status',
|
||||
'/api/coding/files/find?query=app+file&limit=25',
|
||||
'/api/coding/files/content?path=src%2Fapp+file.ts',
|
||||
'/api/coding/search?pattern=hello+world',
|
||||
'/api/coding/skills?agentId=builder%2Fone',
|
||||
'/api/coding/conversations/conversation%2Fone/commands',
|
||||
'/api/coding/conversations/conversation%2Fone/changes',
|
||||
]);
|
||||
expect(JSON.stringify(hostApiFetchMock.mock.calls)).not.toContain('/api/opencode');
|
||||
});
|
||||
});
|
||||
111
tests/unit/coding-project-files.test.ts
Normal file
111
tests/unit/coding-project-files.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, 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 { CodingProjectFileService } from '../../electron/coding-projects/project-files';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const roots: string[] = [];
|
||||
|
||||
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 git(root: string, ...args: string[]): Promise<void> {
|
||||
await exec('git', ['-C', root, ...args], { windowsHide: true });
|
||||
}
|
||||
|
||||
async function initializeRepository(root: string): Promise<void> {
|
||||
await git(root, 'init');
|
||||
await git(root, 'config', 'user.email', 'pi-files@example.invalid');
|
||||
await git(root, 'config', 'user.name', 'PI Files');
|
||||
await mkdir(path.join(root, 'src'), { recursive: true });
|
||||
await writeFile(path.join(root, 'src', 'app.ts'), 'export const message = "baseline";\n', 'utf8');
|
||||
await writeFile(path.join(root, '.gitignore'), 'ignored.txt\n', 'utf8');
|
||||
await git(root, 'add', '.');
|
||||
await git(root, 'commit', '-m', 'baseline');
|
||||
}
|
||||
|
||||
describe('PI-105 project file service', () => {
|
||||
it('reports Git status and finds tracked or untracked files using project-relative DTOs', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-files-git-');
|
||||
await initializeRepository(root);
|
||||
await writeFile(path.join(root, 'src', 'app.ts'), 'export const message = "changed";\n', 'utf8');
|
||||
await writeFile(path.join(root, 'src', 'app.test.ts'), 'test("changed", () => true);\n', 'utf8');
|
||||
await writeFile(path.join(root, 'ignored.txt'), 'ignored\n', 'utf8');
|
||||
|
||||
const service = new CodingProjectFileService();
|
||||
expect(await service.status(root)).toEqual([
|
||||
expect.objectContaining({ path: 'src/app.ts', name: 'app.ts', status: 'modified' }),
|
||||
expect.objectContaining({ path: 'src/app.test.ts', name: 'app.test.ts', status: 'untracked' }),
|
||||
]);
|
||||
const found = await service.find(root, 'app', 1);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toMatchObject({ path: 'src/app.ts', name: 'app.ts', type: 'file' });
|
||||
expect(JSON.stringify({ status: await service.status(root), found })).not.toContain(root);
|
||||
expect((await service.find(root, 'ignored')).map(({ path: filePath }) => filePath)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns bounded UTF-8 content and rejects absolute, traversal, and binary reads', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-files-content-');
|
||||
await writeFile(path.join(root, 'notes.txt'), 'hello project\n', 'utf8');
|
||||
await writeFile(path.join(root, 'large.txt'), 'x'.repeat((256 * 1024) + 100), 'utf8');
|
||||
await writeFile(path.join(root, 'large-unicode.txt'), '你'.repeat(90_000), 'utf8');
|
||||
await writeFile(path.join(root, 'binary.bin'), Buffer.from([1, 0, 2]));
|
||||
const service = new CodingProjectFileService();
|
||||
|
||||
expect(await service.content(root, 'notes.txt')).toEqual({
|
||||
path: 'notes.txt', content: 'hello project\n', truncated: false,
|
||||
});
|
||||
const large = await service.content(root, 'large.txt');
|
||||
expect(Buffer.byteLength(large.content, 'utf8')).toBe(256 * 1024);
|
||||
expect(large.truncated).toBe(true);
|
||||
const largeUnicode = await service.content(root, 'large-unicode.txt');
|
||||
expect(Buffer.byteLength(largeUnicode.content, 'utf8')).toBeLessThanOrEqual(256 * 1024);
|
||||
expect(largeUnicode.content).toMatch(/^你+$/u);
|
||||
expect(largeUnicode.truncated).toBe(true);
|
||||
await expect(service.content(root, '../secret.txt')).rejects.toThrow('escapes the active project');
|
||||
await expect(service.content(root, path.resolve(root, 'notes.txt'))).rejects.toThrow('must be relative');
|
||||
await expect(service.content(root, 'binary.bin')).rejects.toThrow('Binary project files');
|
||||
});
|
||||
|
||||
it('searches literal text with bounded relative matches and falls back outside Git', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-files-search-');
|
||||
await mkdir(path.join(root, 'docs'), { recursive: true });
|
||||
await mkdir(path.join(root, 'node_modules', 'private-package'), { recursive: true });
|
||||
await writeFile(path.join(root, 'docs', 'guide.md'), 'First line\nNeedle and needle again\n', 'utf8');
|
||||
await writeFile(path.join(root, 'node_modules', 'private-package', 'secret.txt'), 'needle\n', 'utf8');
|
||||
const service = new CodingProjectFileService({
|
||||
async run() {
|
||||
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
|
||||
},
|
||||
});
|
||||
|
||||
expect((await service.find(root, 'guide')).map(({ path: filePath }) => filePath)).toEqual([
|
||||
'docs/guide.md',
|
||||
]);
|
||||
const matches = await service.search(root, 'needle');
|
||||
expect(matches).toEqual([{
|
||||
path: 'docs/guide.md',
|
||||
name: 'guide.md',
|
||||
lineNumber: 2,
|
||||
lineText: 'Needle and needle again',
|
||||
submatches: [
|
||||
{ text: 'Needle', start: 0, end: 6 },
|
||||
{ text: 'needle', start: 11, end: 17 },
|
||||
],
|
||||
}]);
|
||||
expect(JSON.stringify(matches)).not.toContain(root);
|
||||
expect(JSON.stringify(matches)).not.toContain('private-package');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user