qwen-code/packages/core/src/utils/schemaValidator.test.ts
tanzhenxin 0eaa5e4561 fix(core): coerce stringified JSON values for anyOf/oneOf schemas (#2839)
Some LLMs serialize array/object values as JSON strings when the schema
uses anyOf/oneOf with mixed types (e.g., Python's `list[str] | None`).
The model returns '["url"]' (a string) instead of ["url"] (an array),
causing Ajv to reject otherwise valid input.

Add fixStringifiedJsonValues() coercion — same pattern as the existing
fixBooleanValues() — that parses stringified arrays/objects back to their
intended type when the schema expects a non-string type.
2026-04-03 23:02:25 +08:00

446 lines
13 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { SchemaValidator } from './schemaValidator.js';
describe('SchemaValidator', () => {
it('should allow any params if schema is undefined', () => {
const params = {
foo: 'bar',
};
expect(SchemaValidator.validate(undefined, params)).toBeNull();
});
it('rejects null params', () => {
const schema = {
type: 'object',
properties: {
foo: {
type: 'string',
},
},
};
expect(SchemaValidator.validate(schema, null)).toBe(
'Value of params must be an object',
);
});
it('rejects params that are not objects', () => {
const schema = {
type: 'object',
properties: {
foo: {
type: 'string',
},
},
};
expect(SchemaValidator.validate(schema, 'not an object')).toBe(
'Value of params must be an object',
);
});
it('allows schema with extra properties', () => {
const schema = {
type: 'object',
properties: {
example_enum: {
type: 'string',
enum: ['FOO', 'BAR'],
// enum-descriptions is not part of the JSON schema spec.
// This test verifies that the SchemaValidator allows the
// use of extra keywords, like this one, in the schema.
'enum-descriptions': ['a foo', 'a bar'],
},
},
};
const params = {
example_enum: 'BAR',
};
expect(SchemaValidator.validate(schema, params)).toBeNull();
});
it('allows custom format values', () => {
const schema = {
type: 'object',
properties: {
duration: {
type: 'string',
// See: https://cloud.google.com/docs/discovery/type-format
format: 'google-duration',
},
mask: {
type: 'string',
format: 'google-fieldmask',
},
foo: {
type: 'string',
format: 'something-totally-custom',
},
},
};
const params = {
duration: '10s',
mask: 'foo.bar,biz.baz',
foo: 'some value',
};
expect(SchemaValidator.validate(schema, params)).toBeNull();
});
it('allows valid values for known formats', () => {
const schema = {
type: 'object',
properties: {
today: {
type: 'string',
format: 'date',
},
},
};
const params = {
today: '2025-04-08',
};
expect(SchemaValidator.validate(schema, params)).toBeNull();
});
it('rejects invalid values for known formats', () => {
const schema = {
type: 'object',
properties: {
today: {
type: 'string',
format: 'date',
},
},
};
const params = {
today: 'this is not a date',
};
expect(SchemaValidator.validate(schema, params)).not.toBeNull();
});
describe('boolean string coercion', () => {
const booleanSchema = {
type: 'object',
properties: {
is_background: {
type: 'boolean',
},
},
required: ['is_background'],
};
it('should coerce string "true" to boolean true', () => {
const params = { is_background: 'true' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
it('should coerce string "True" to boolean true', () => {
const params = { is_background: 'True' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
it('should coerce string "TRUE" to boolean true', () => {
const params = { is_background: 'TRUE' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
it('should coerce string "false" to boolean false', () => {
const params = { is_background: 'false' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(false);
});
it('should coerce string "False" to boolean false', () => {
const params = { is_background: 'False' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(false);
});
it('should coerce string "FALSE" to boolean false', () => {
const params = { is_background: 'FALSE' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(false);
});
it('should handle nested objects with string booleans', () => {
const nestedSchema = {
type: 'object',
properties: {
options: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
},
},
},
};
const params = { options: { enabled: 'true' } };
expect(SchemaValidator.validate(nestedSchema, params)).toBeNull();
expect((params.options as unknown as { enabled: boolean }).enabled).toBe(
true,
);
});
it('should not affect non-boolean strings', () => {
const mixedSchema = {
type: 'object',
properties: {
name: { type: 'string' },
is_active: { type: 'boolean' },
},
};
const params = { name: 'trueman', is_active: 'true' };
expect(SchemaValidator.validate(mixedSchema, params)).toBeNull();
expect(params.name).toBe('trueman');
expect(params.is_active).toBe(true);
});
it('should pass through actual boolean values unchanged', () => {
const params = { is_background: true };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
});
describe('stringified JSON value coercion', () => {
it('should coerce stringified array for anyOf [array, null]', () => {
const schema = {
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
default: null,
},
},
};
const params = { urls: '["https://example.com"]' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.urls).toEqual(['https://example.com']);
});
it('should coerce stringified object for anyOf [object, null]', () => {
const schema = {
type: 'object',
properties: {
config: {
anyOf: [
{
type: 'object',
properties: { key: { type: 'string' } },
},
{ type: 'null' },
],
},
},
};
const params = { config: '{"key":"value"}' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.config).toEqual({ key: 'value' });
});
it('should coerce stringified array for oneOf [array, null]', () => {
const schema = {
type: 'object',
properties: {
items: {
oneOf: [
{ type: 'array', items: { type: 'integer' } },
{ type: 'null' },
],
},
},
};
const params = { items: '[1, 2, 3]' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.items).toEqual([1, 2, 3]);
});
it('should not coerce when schema accepts string type', () => {
const schema = {
type: 'object',
properties: {
data: {
anyOf: [
{ type: 'string' },
{ type: 'array', items: { type: 'string' } },
],
},
},
};
const params = { data: '["hello"]' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
// Value should remain a string since string is accepted
expect(params.data).toBe('["hello"]');
});
it('should not coerce invalid JSON strings', () => {
const schema = {
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
},
},
};
const params = { urls: '[not valid json' };
expect(SchemaValidator.validate(schema, params)).not.toBeNull();
});
it('should not coerce strings that do not look like JSON', () => {
const schema = {
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
},
},
required: ['urls'],
};
const params = { urls: 'hello world' };
expect(SchemaValidator.validate(schema, params)).not.toBeNull();
});
it('should handle stringified array with plain type (no anyOf)', () => {
// Should NOT coerce when there is no anyOf/oneOf — the schema just
// says type: array, and a string value is simply invalid.
const schema = {
type: 'object',
properties: {
urls: { type: 'array', items: { type: 'string' } },
},
required: ['urls'],
};
const params = { urls: '["https://example.com"]' };
// No anyOf/oneOf, so fixStringifiedJsonValues won't have types to check
// against — but getAcceptedTypes reads plain 'type' too, so it should
// still coerce since 'string' is not in the accepted types.
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.urls).toEqual(['https://example.com']);
});
});
describe('JSON Schema version support', () => {
it('should support JSON Schema draft-2020-12', () => {
const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
url: { type: 'string' },
},
required: ['url'],
};
const params = { url: 'https://example.com' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
});
it('should validate correctly with draft-2020-12 schema', () => {
const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
count: { type: 'integer' },
},
required: ['count'],
};
const validParams = { count: 42 };
const invalidParams = { count: 'not a number' };
expect(SchemaValidator.validate(schema, validParams)).toBeNull();
expect(SchemaValidator.validate(schema, invalidParams)).not.toBeNull();
});
it('should support JSON Schema draft-07 (default)', () => {
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
};
const params = { name: 'test' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
});
it('should handle nested schemas with $schema', () => {
const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
config: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
},
},
},
};
const params = { config: { enabled: true } };
expect(SchemaValidator.validate(schema, params)).toBeNull();
});
it('should support 2020-12 specific keywords like prefixItems', () => {
const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'array',
prefixItems: [{ type: 'string' }, { type: 'integer' }],
};
const params = ['hello', 42];
expect(SchemaValidator.validate(schema, params)).toBeNull();
});
it('should handle anyOf union types with draft-2020-12', () => {
const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
default: null,
},
},
};
expect(
SchemaValidator.validate(schema, {
urls: ['https://example.com'],
}),
).toBeNull();
expect(SchemaValidator.validate(schema, { urls: null })).toBeNull();
expect(SchemaValidator.validate(schema, {})).toBeNull();
});
it('should gracefully handle unsupported schema versions', () => {
// draft-2019-09 is not supported by Ajv by default
const schema = {
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: {
value: { type: 'string' },
},
};
const params = { value: 'test' };
// Should skip validation and return null (graceful degradation)
expect(SchemaValidator.validate(schema, params)).toBeNull();
});
});
});