// @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: '指定的项目文件不存在。' }, }); 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 }); }); });