From e89af9042caa11f7a744cec5b96c6e113e69e422 Mon Sep 17 00:00:00 2001 From: suyao Date: Thu, 18 Dec 2025 15:39:21 +0800 Subject: [PATCH] test(schema): add comprehensive tests for jsonSchemaToZod function --- .../__tests__/jsonSchemaToZod.test.ts | 340 ++++++++++++++++++ .../apiServer/services/unified-messages.ts | 66 ++-- tests/main.setup.ts | 53 ++- 3 files changed, 427 insertions(+), 32 deletions(-) create mode 100644 src/main/apiServer/services/__tests__/jsonSchemaToZod.test.ts diff --git a/src/main/apiServer/services/__tests__/jsonSchemaToZod.test.ts b/src/main/apiServer/services/__tests__/jsonSchemaToZod.test.ts new file mode 100644 index 0000000000..804db0d357 --- /dev/null +++ b/src/main/apiServer/services/__tests__/jsonSchemaToZod.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from 'vitest' +import * as z from 'zod' + +import { type JsonSchemaLike, jsonSchemaToZod } from '../unified-messages' + +describe('jsonSchemaToZod', () => { + describe('Basic Types', () => { + it('should convert string type', () => { + const schema: JsonSchemaLike = { type: 'string' } + const result = jsonSchemaToZod(schema) + expect(result).toBeInstanceOf(z.ZodString) + expect(result.safeParse('hello').success).toBe(true) + expect(result.safeParse(123).success).toBe(false) + }) + + it('should convert string with minLength', () => { + const schema: JsonSchemaLike = { type: 'string', minLength: 3 } + const result = jsonSchemaToZod(schema) + expect(result.safeParse('ab').success).toBe(false) + expect(result.safeParse('abc').success).toBe(true) + }) + + it('should convert string with maxLength', () => { + const schema: JsonSchemaLike = { type: 'string', maxLength: 5 } + const result = jsonSchemaToZod(schema) + expect(result.safeParse('hello').success).toBe(true) + expect(result.safeParse('hello world').success).toBe(false) + }) + + it('should convert string with pattern', () => { + const schema: JsonSchemaLike = { type: 'string', pattern: '^[0-9]+$' } + const result = jsonSchemaToZod(schema) + expect(result.safeParse('123').success).toBe(true) + expect(result.safeParse('abc').success).toBe(false) + }) + + it('should convert number type', () => { + const schema: JsonSchemaLike = { type: 'number' } + const result = jsonSchemaToZod(schema) + expect(result).toBeInstanceOf(z.ZodNumber) + expect(result.safeParse(42).success).toBe(true) + expect(result.safeParse(3.14).success).toBe(true) + expect(result.safeParse('42').success).toBe(false) + }) + + it('should convert integer type', () => { + const schema: JsonSchemaLike = { type: 'integer' } + const result = jsonSchemaToZod(schema) + expect(result.safeParse(42).success).toBe(true) + expect(result.safeParse(3.14).success).toBe(false) + }) + + it('should convert number with minimum', () => { + const schema: JsonSchemaLike = { type: 'number', minimum: 10 } + const result = jsonSchemaToZod(schema) + expect(result.safeParse(5).success).toBe(false) + expect(result.safeParse(10).success).toBe(true) + expect(result.safeParse(15).success).toBe(true) + }) + + it('should convert number with maximum', () => { + const schema: JsonSchemaLike = { type: 'number', maximum: 100 } + const result = jsonSchemaToZod(schema) + expect(result.safeParse(50).success).toBe(true) + expect(result.safeParse(100).success).toBe(true) + expect(result.safeParse(150).success).toBe(false) + }) + + it('should convert boolean type', () => { + const schema: JsonSchemaLike = { type: 'boolean' } + const result = jsonSchemaToZod(schema) + expect(result).toBeInstanceOf(z.ZodBoolean) + expect(result.safeParse(true).success).toBe(true) + expect(result.safeParse(false).success).toBe(true) + expect(result.safeParse('true').success).toBe(false) + }) + + it('should convert null type', () => { + const schema: JsonSchemaLike = { type: 'null' } + const result = jsonSchemaToZod(schema) + expect(result).toBeInstanceOf(z.ZodNull) + expect(result.safeParse(null).success).toBe(true) + expect(result.safeParse(undefined).success).toBe(false) + }) + }) + + describe('Enum Types', () => { + it('should convert string enum', () => { + const schema: JsonSchemaLike = { enum: ['red', 'green', 'blue'] } + const result = jsonSchemaToZod(schema) + expect(result.safeParse('red').success).toBe(true) + expect(result.safeParse('green').success).toBe(true) + expect(result.safeParse('yellow').success).toBe(false) + }) + + it('should convert non-string enum with literals', () => { + const schema: JsonSchemaLike = { enum: [1, 2, 3] } + const result = jsonSchemaToZod(schema) + expect(result.safeParse(1).success).toBe(true) + expect(result.safeParse(2).success).toBe(true) + expect(result.safeParse(4).success).toBe(false) + }) + + it('should convert single value enum', () => { + const schema: JsonSchemaLike = { enum: ['only'] } + const result = jsonSchemaToZod(schema) + expect(result.safeParse('only').success).toBe(true) + expect(result.safeParse('other').success).toBe(false) + }) + + it('should convert mixed enum', () => { + const schema: JsonSchemaLike = { enum: ['text', 1, true] } + const result = jsonSchemaToZod(schema) + expect(result.safeParse('text').success).toBe(true) + expect(result.safeParse(1).success).toBe(true) + expect(result.safeParse(true).success).toBe(true) + expect(result.safeParse(false).success).toBe(false) + }) + }) + + describe('Array Types', () => { + it('should convert array of strings', () => { + const schema: JsonSchemaLike = { + type: 'array', + items: { type: 'string' } + } + const result = jsonSchemaToZod(schema) + expect(result.safeParse(['a', 'b']).success).toBe(true) + expect(result.safeParse([1, 2]).success).toBe(false) + }) + + it('should convert array without items (unknown)', () => { + const schema: JsonSchemaLike = { type: 'array' } + const result = jsonSchemaToZod(schema) + expect(result.safeParse([]).success).toBe(true) + expect(result.safeParse(['a', 1, true]).success).toBe(true) + }) + + it('should convert array with minItems', () => { + const schema: JsonSchemaLike = { + type: 'array', + items: { type: 'number' }, + minItems: 2 + } + const result = jsonSchemaToZod(schema) + expect(result.safeParse([1]).success).toBe(false) + expect(result.safeParse([1, 2]).success).toBe(true) + }) + + it('should convert array with maxItems', () => { + const schema: JsonSchemaLike = { + type: 'array', + items: { type: 'number' }, + maxItems: 3 + } + const result = jsonSchemaToZod(schema) + expect(result.safeParse([1, 2, 3]).success).toBe(true) + expect(result.safeParse([1, 2, 3, 4]).success).toBe(false) + }) + }) + + describe('Object Types', () => { + it('should convert simple object', () => { + const schema: JsonSchemaLike = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + } + } + const result = jsonSchemaToZod(schema) + expect(result.safeParse({ name: 'John', age: 30 }).success).toBe(true) + expect(result.safeParse({ name: 'John', age: '30' }).success).toBe(false) + }) + + it('should handle required fields', () => { + const schema: JsonSchemaLike = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name'] + } + const result = jsonSchemaToZod(schema) + expect(result.safeParse({ name: 'John', age: 30 }).success).toBe(true) + expect(result.safeParse({ age: 30 }).success).toBe(false) + expect(result.safeParse({ name: 'John' }).success).toBe(true) + }) + + it('should convert empty object', () => { + const schema: JsonSchemaLike = { type: 'object' } + const result = jsonSchemaToZod(schema) + expect(result.safeParse({}).success).toBe(true) + }) + + it('should convert nested objects', () => { + const schema: JsonSchemaLike = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' } + } + } + } + } + const result = jsonSchemaToZod(schema) + expect(result.safeParse({ user: { name: 'John', email: 'john@example.com' } }).success).toBe(true) + expect(result.safeParse({ user: { name: 'John' } }).success).toBe(true) + }) + }) + + describe('Union Types', () => { + it('should convert union type (type array)', () => { + const schema: JsonSchemaLike = { type: ['string', 'null'] } + const result = jsonSchemaToZod(schema) + expect(result.safeParse('hello').success).toBe(true) + expect(result.safeParse(null).success).toBe(true) + expect(result.safeParse(123).success).toBe(false) + }) + + it('should convert single type array', () => { + const schema: JsonSchemaLike = { type: ['string'] } + const result = jsonSchemaToZod(schema) + expect(result.safeParse('hello').success).toBe(true) + expect(result.safeParse(123).success).toBe(false) + }) + + it('should convert multiple union types', () => { + const schema: JsonSchemaLike = { type: ['string', 'number', 'boolean'] } + const result = jsonSchemaToZod(schema) + expect(result.safeParse('text').success).toBe(true) + expect(result.safeParse(42).success).toBe(true) + expect(result.safeParse(true).success).toBe(true) + expect(result.safeParse(null).success).toBe(false) + }) + }) + + describe('Description Handling', () => { + it('should preserve description for string', () => { + const schema: JsonSchemaLike = { + type: 'string', + description: 'A user name' + } + const result = jsonSchemaToZod(schema) + expect(result.description).toBe('A user name') + }) + + it('should preserve description for enum', () => { + const schema: JsonSchemaLike = { + enum: ['red', 'green', 'blue'], + description: 'Available colors' + } + const result = jsonSchemaToZod(schema) + expect(result.description).toBe('Available colors') + }) + + it('should preserve description for object', () => { + const schema: JsonSchemaLike = { + type: 'object', + description: 'User object', + properties: { + name: { type: 'string' } + } + } + const result = jsonSchemaToZod(schema) + expect(result.description).toBe('User object') + }) + }) + + describe('Edge Cases', () => { + it('should handle unknown type', () => { + const schema: JsonSchemaLike = { type: 'unknown-type' as any } + const result = jsonSchemaToZod(schema) + expect(result).toBeInstanceOf(z.ZodType) + expect(result.safeParse(anything).success).toBe(true) + }) + + it('should handle schema without type', () => { + const schema: JsonSchemaLike = {} + const result = jsonSchemaToZod(schema) + expect(result).toBeInstanceOf(z.ZodType) + expect(result.safeParse(anything).success).toBe(true) + }) + + it('should handle complex nested schema', () => { + const schema: JsonSchemaLike = { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + tags: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['id'] + } + } + } + } + const result = jsonSchemaToZod(schema) + const validData = { + items: [ + { id: 1, name: 'Item 1', tags: ['tag1', 'tag2'] }, + { id: 2, tags: [] } + ] + } + expect(result.safeParse(validData).success).toBe(true) + + const invalidData = { + items: [{ name: 'No ID' }] + } + expect(result.safeParse(invalidData).success).toBe(false) + }) + }) + + describe('OpenRouter Model IDs', () => { + it('should handle model identifier format with colons', () => { + const schema: JsonSchemaLike = { + type: 'string', + enum: ['openrouter:anthropic/claude-3.5-sonnet:free', 'openrouter:gpt-4:paid'] + } + const result = jsonSchemaToZod(schema) + expect(result.safeParse('openrouter:anthropic/claude-3.5-sonnet:free').success).toBe(true) + expect(result.safeParse('openrouter:gpt-4:paid').success).toBe(true) + expect(result.safeParse('other').success).toBe(false) + }) + }) +}) + +const anything = Math.random() > 0.5 ? 'string' : Math.random() > 0.5 ? 123 : { a: true } diff --git a/src/main/apiServer/services/unified-messages.ts b/src/main/apiServer/services/unified-messages.ts index 5525d85c44..38c0f61d3e 100644 --- a/src/main/apiServer/services/unified-messages.ts +++ b/src/main/apiServer/services/unified-messages.ts @@ -1,7 +1,7 @@ import type { AnthropicProviderOptions } from '@ai-sdk/anthropic' import type { GoogleGenerativeAIProviderOptions } from '@ai-sdk/google' import type { OpenAIResponsesProviderOptions } from '@ai-sdk/openai' -import type { LanguageModelV2Middleware, LanguageModelV2ToolResultOutput } from '@ai-sdk/provider' +import type { JSONSchema7, LanguageModelV2Middleware, LanguageModelV2ToolResultOutput } from '@ai-sdk/provider' import type { ProviderOptions, ReasoningPart, ToolCallPart, ToolResultPart } from '@ai-sdk/provider-utils' import type { ImageBlockParam, @@ -143,18 +143,20 @@ function convertAnthropicToolResultToAiSdk( return { type: 'content', value: values } } -// Type alias for JSON Schema (compatible with recursive calls) -type JsonSchemaLike = AnthropicTool.InputSchema | Record +/** + * JSON Schema type for tool input schemas + * Uses the standard JSONSchema7 type from the json-schema package (via @ai-sdk/provider) + */ +export type JsonSchemaLike = JSONSchema7 /** * Convert JSON Schema to Zod schema * This avoids non-standard fields like input_examples that Anthropic doesn't support */ -function jsonSchemaToZod(schema: JsonSchemaLike): z.ZodTypeAny { - const s = schema as Record - const schemaType = s.type as string | string[] | undefined - const enumValues = s.enum as unknown[] | undefined - const description = s.description as string | undefined +export function jsonSchemaToZod(schema: JsonSchemaLike): z.ZodTypeAny { + const schemaType = schema.type + const enumValues = schema.enum + const description = schema.description // Handle enum first if (enumValues && Array.isArray(enumValues) && enumValues.length > 0) { @@ -173,7 +175,13 @@ function jsonSchemaToZod(schema: JsonSchemaLike): z.ZodTypeAny { // Handle union types (type: ["string", "null"]) if (Array.isArray(schemaType)) { - const schemas = schemaType.map((t) => jsonSchemaToZod({ ...s, type: t, enum: undefined })) + const schemas = schemaType.map((t) => + jsonSchemaToZod({ + ...schema, + type: t, + enum: undefined + }) + ) if (schemas.length === 1) { return schemas[0] } @@ -184,17 +192,17 @@ function jsonSchemaToZod(schema: JsonSchemaLike): z.ZodTypeAny { switch (schemaType) { case 'string': { let zodString = z.string() - if (typeof s.minLength === 'number') zodString = zodString.min(s.minLength) - if (typeof s.maxLength === 'number') zodString = zodString.max(s.maxLength) - if (typeof s.pattern === 'string') zodString = zodString.regex(new RegExp(s.pattern)) + if (typeof schema.minLength === 'number') zodString = zodString.min(schema.minLength) + if (typeof schema.maxLength === 'number') zodString = zodString.max(schema.maxLength) + if (typeof schema.pattern === 'string') zodString = zodString.regex(new RegExp(schema.pattern)) return description ? zodString.describe(description) : zodString } case 'number': case 'integer': { let zodNumber = schemaType === 'integer' ? z.number().int() : z.number() - if (typeof s.minimum === 'number') zodNumber = zodNumber.min(s.minimum) - if (typeof s.maximum === 'number') zodNumber = zodNumber.max(s.maximum) + if (typeof schema.minimum === 'number') zodNumber = zodNumber.min(schema.minimum) + if (typeof schema.maximum === 'number') zodNumber = zodNumber.max(schema.maximum) return description ? zodNumber.describe(description) : zodNumber } @@ -207,24 +215,33 @@ function jsonSchemaToZod(schema: JsonSchemaLike): z.ZodTypeAny { return z.null() case 'array': { - const items = s.items as Record | undefined - let zodArray = items ? z.array(jsonSchemaToZod(items)) : z.array(z.unknown()) - if (typeof s.minItems === 'number') zodArray = zodArray.min(s.minItems) - if (typeof s.maxItems === 'number') zodArray = zodArray.max(s.maxItems) + const items = schema.items + let zodArray: z.ZodArray + if (items && typeof items === 'object' && !Array.isArray(items)) { + zodArray = z.array(jsonSchemaToZod(items as JsonSchemaLike)) + } else { + zodArray = z.array(z.unknown()) + } + if (typeof schema.minItems === 'number') zodArray = zodArray.min(schema.minItems) + if (typeof schema.maxItems === 'number') zodArray = zodArray.max(schema.maxItems) return description ? zodArray.describe(description) : zodArray } case 'object': { - const properties = s.properties as Record> | undefined - const required = (s.required as string[]) || [] + const properties = schema.properties + const required = schema.required || [] // Always use z.object() to ensure "properties" field is present in output schema // OpenAI requires explicit properties field even for empty objects const shape: Record = {} - if (properties) { + if (properties && typeof properties === 'object') { for (const [key, propSchema] of Object.entries(properties)) { - const zodProp = jsonSchemaToZod(propSchema) - shape[key] = required.includes(key) ? zodProp : zodProp.optional() + if (typeof propSchema === 'boolean') { + shape[key] = propSchema ? z.unknown() : z.never() + } else { + const zodProp = jsonSchemaToZod(propSchema as JsonSchemaLike) + shape[key] = required.includes(key) ? zodProp : zodProp.optional() + } } } @@ -246,7 +263,8 @@ function convertAnthropicToolsToAiSdk(tools: MessageCreateParams['tools']): Reco if (anthropicTool.type === 'bash_20250124') continue const toolDef = anthropicTool as AnthropicTool const rawSchema = toolDef.input_schema - const schema = jsonSchemaToZod(rawSchema) + // Convert Anthropic's InputSchema to JSONSchema7-compatible format + const schema = jsonSchemaToZod(rawSchema as JsonSchemaLike) // Use tool() with inputSchema (AI SDK v5 API) const aiTool = tool({ diff --git a/tests/main.setup.ts b/tests/main.setup.ts index 5cadb89d02..2370781564 100644 --- a/tests/main.setup.ts +++ b/tests/main.setup.ts @@ -61,7 +61,19 @@ vi.mock('electron', () => ({ getPrimaryDisplay: vi.fn(), getAllDisplays: vi.fn() }, - Notification: vi.fn() + Notification: vi.fn(), + net: { + fetch: vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: vi.fn(() => Promise.resolve({})), + text: vi.fn(() => Promise.resolve('')), + headers: new Headers() + }) + ) + } })) // Mock Winston for LoggerService dependencies @@ -97,15 +109,40 @@ vi.mock('winston-daily-rotate-file', () => { })) }) -// Mock Node.js modules -vi.mock('node:os', () => ({ - platform: vi.fn(() => 'darwin'), - arch: vi.fn(() => 'x64'), - version: vi.fn(() => '20.0.0'), - cpus: vi.fn(() => [{ model: 'Mock CPU' }]), - totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB +// Mock main process services +vi.mock('@main/services/AnthropicService', () => ({ + default: {} })) +vi.mock('@main/services/CopilotService', () => ({ + default: {} +})) + +vi.mock('@main/services/ReduxService', () => ({ + reduxService: { + selectSync: vi.fn() + } +})) + +vi.mock('@main/integration/cherryai', () => ({ + generateSignature: vi.fn() +})) + +// Mock Node.js modules +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + default: actual, + platform: vi.fn(() => 'darwin'), + arch: vi.fn(() => 'x64'), + version: vi.fn(() => '20.0.0'), + cpus: vi.fn(() => [{ model: 'Mock CPU' }]), + totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024), // 8GB + homedir: vi.fn(() => '/tmp') + } +}) + vi.mock('node:path', async () => { const actual = await vi.importActual('node:path') return {