165 lines
5.2 KiB
TypeScript
165 lines
5.2 KiB
TypeScript
/**
|
||
* Claude SDK layer structured output tests.
|
||
*
|
||
* Tests two internal components:
|
||
* 1. SdkOptionsBuilder — outputSchema → outputFormat conversion
|
||
* 2. QueryExecutor — structured_output extraction from SDK result messages
|
||
*/
|
||
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
// ===== SdkOptionsBuilder tests (no mock needed) =====
|
||
|
||
import { buildSdkOptions } from '../infra/claude/options-builder.js';
|
||
|
||
describe('SdkOptionsBuilder — outputFormat 変換', () => {
|
||
it('outputSchema が outputFormat に変換される', () => {
|
||
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
|
||
const sdkOptions = buildSdkOptions({ cwd: '/tmp', outputSchema: schema });
|
||
|
||
expect((sdkOptions as Record<string, unknown>).outputFormat).toEqual({
|
||
type: 'json_schema',
|
||
schema,
|
||
});
|
||
});
|
||
|
||
it('outputSchema 未設定なら outputFormat は含まれない', () => {
|
||
const sdkOptions = buildSdkOptions({ cwd: '/tmp' });
|
||
expect(sdkOptions).not.toHaveProperty('outputFormat');
|
||
});
|
||
});
|
||
|
||
// ===== QueryExecutor tests (mock @anthropic-ai/claude-agent-sdk) =====
|
||
|
||
const { mockQuery } = vi.hoisted(() => ({
|
||
mockQuery: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
|
||
query: mockQuery,
|
||
AbortError: class AbortError extends Error {
|
||
constructor(message?: string) {
|
||
super(message);
|
||
this.name = 'AbortError';
|
||
}
|
||
},
|
||
}));
|
||
|
||
// QueryExecutor は executor.ts 内で query() を使うため、mock 後にインポート
|
||
const { QueryExecutor } = await import('../infra/claude/executor.js');
|
||
|
||
/**
|
||
* query() が返す Query オブジェクト(async iterable + interrupt)のモック
|
||
*/
|
||
function createMockQuery(messages: Array<Record<string, unknown>>) {
|
||
return {
|
||
[Symbol.asyncIterator]: async function* () {
|
||
for (const msg of messages) {
|
||
yield msg;
|
||
}
|
||
},
|
||
interrupt: vi.fn(),
|
||
};
|
||
}
|
||
|
||
describe('QueryExecutor — structuredOutput 抽出', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('result メッセージの structured_output (snake_case) を抽出する', async () => {
|
||
mockQuery.mockReturnValue(createMockQuery([
|
||
{ type: 'result', subtype: 'success', result: 'done', structured_output: { step: 2 } },
|
||
]));
|
||
|
||
const executor = new QueryExecutor();
|
||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||
|
||
expect(result.success).toBe(true);
|
||
expect(result.structuredOutput).toEqual({ step: 2 });
|
||
});
|
||
|
||
it('result メッセージの structuredOutput (camelCase) を抽出する', async () => {
|
||
mockQuery.mockReturnValue(createMockQuery([
|
||
{ type: 'result', subtype: 'success', result: 'done', structuredOutput: { step: 3 } },
|
||
]));
|
||
|
||
const executor = new QueryExecutor();
|
||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||
|
||
expect(result.structuredOutput).toEqual({ step: 3 });
|
||
});
|
||
|
||
it('structured_output が snake_case 優先 (snake_case と camelCase 両方ある場合)', async () => {
|
||
mockQuery.mockReturnValue(createMockQuery([
|
||
{
|
||
type: 'result',
|
||
subtype: 'success',
|
||
result: 'done',
|
||
structured_output: { step: 1 },
|
||
structuredOutput: { step: 9 },
|
||
},
|
||
]));
|
||
|
||
const executor = new QueryExecutor();
|
||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||
|
||
expect(result.structuredOutput).toEqual({ step: 1 });
|
||
});
|
||
|
||
it('structuredOutput がない場合は undefined', async () => {
|
||
mockQuery.mockReturnValue(createMockQuery([
|
||
{ type: 'result', subtype: 'success', result: 'plain text' },
|
||
]));
|
||
|
||
const executor = new QueryExecutor();
|
||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||
|
||
expect(result.structuredOutput).toBeUndefined();
|
||
});
|
||
|
||
it('structured_output が配列の場合は無視する', async () => {
|
||
mockQuery.mockReturnValue(createMockQuery([
|
||
{ type: 'result', subtype: 'success', result: 'done', structured_output: [1, 2, 3] },
|
||
]));
|
||
|
||
const executor = new QueryExecutor();
|
||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||
|
||
expect(result.structuredOutput).toBeUndefined();
|
||
});
|
||
|
||
it('structured_output が null の場合は無視する', async () => {
|
||
mockQuery.mockReturnValue(createMockQuery([
|
||
{ type: 'result', subtype: 'success', result: 'done', structured_output: null },
|
||
]));
|
||
|
||
const executor = new QueryExecutor();
|
||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||
|
||
expect(result.structuredOutput).toBeUndefined();
|
||
});
|
||
|
||
it('assistant テキストと structured_output を同時に取得する', async () => {
|
||
mockQuery.mockReturnValue(createMockQuery([
|
||
{
|
||
type: 'assistant',
|
||
message: { content: [{ type: 'text', text: 'thinking...' }] },
|
||
},
|
||
{
|
||
type: 'result',
|
||
subtype: 'success',
|
||
result: 'final text',
|
||
structured_output: { step: 1, reason: 'approved' },
|
||
},
|
||
]));
|
||
|
||
const executor = new QueryExecutor();
|
||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||
|
||
expect(result.success).toBe(true);
|
||
expect(result.content).toBe('final text');
|
||
expect(result.structuredOutput).toEqual({ step: 1, reason: 'approved' });
|
||
});
|
||
});
|