feat(coding): add PI-105 product file host API
This commit is contained in:
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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user