test(schema): add comprehensive tests for jsonSchemaToZod function

This commit is contained in:
suyao 2025-12-18 15:39:21 +08:00
parent 08777e0746
commit e89af9042c
No known key found for this signature in database
3 changed files with 427 additions and 32 deletions

View File

@ -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 }

View File

@ -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<string, unknown>
/**
* 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<string, unknown>
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<string, unknown> | 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<z.ZodTypeAny>
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<string, Record<string, unknown>> | 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<string, z.ZodTypeAny> = {}
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({

View File

@ -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<typeof import('node:os')>('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 {