mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
576 lines
16 KiB
TypeScript
576 lines
16 KiB
TypeScript
/**
|
|
* Schema Sanitizer Unit Tests
|
|
*
|
|
* Tests for Gemini-compatible tool input_schema sanitization.
|
|
* Verifies that only Gemini-supported fields are preserved.
|
|
*/
|
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { sanitizeInputSchema, sanitizeToolSchemas } from '../../../src/cliproxy/schema-sanitizer';
|
|
|
|
describe('sanitizeInputSchema', () => {
|
|
test('preserves Gemini-supported properties', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string', description: 'The name' },
|
|
age: { type: 'number', minimum: 0, maximum: 150 },
|
|
},
|
|
required: ['name'],
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(0);
|
|
expect(result.removedPaths).toEqual([]);
|
|
expect(result.schema).toEqual(schema);
|
|
});
|
|
|
|
test('removes non-standard properties at top level', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
cornerRadius: 8,
|
|
fillColor: '#ffffff',
|
|
properties: {
|
|
name: { type: 'string' },
|
|
},
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(2);
|
|
expect(result.removedPaths).toContain('cornerRadius');
|
|
expect(result.removedPaths).toContain('fillColor');
|
|
expect(result.schema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' },
|
|
},
|
|
});
|
|
});
|
|
|
|
test('removes non-standard properties from nested properties', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
style: {
|
|
type: 'object',
|
|
fontFamily: 'Arial',
|
|
fontSize: 12,
|
|
properties: {
|
|
color: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(2);
|
|
expect(result.removedPaths).toContain('properties.style.fontFamily');
|
|
expect(result.removedPaths).toContain('properties.style.fontSize');
|
|
expect(result.schema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
style: {
|
|
type: 'object',
|
|
properties: {
|
|
color: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test('removes non-standard properties from array items', () => {
|
|
const schema = {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
gap: 10,
|
|
padding: 20,
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(2);
|
|
expect(result.removedPaths).toContain('items.gap');
|
|
expect(result.removedPaths).toContain('items.padding');
|
|
expect(result.schema).toEqual({
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test('preserves anyOf and sanitizes nested schemas', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
anyOf: [
|
|
{ type: 'string', customProp: 'remove' },
|
|
{ type: 'number', anotherCustom: 123 },
|
|
],
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(2);
|
|
expect(result.schema).toEqual({
|
|
type: 'object',
|
|
anyOf: [{ type: 'string' }, { type: 'number' }],
|
|
});
|
|
});
|
|
|
|
test('removes oneOf and allOf (not supported by Gemini)', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
oneOf: [{ type: 'string' }, { type: 'number' }],
|
|
allOf: [{ required: ['name'] }],
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(2);
|
|
expect(result.removedPaths).toContain('oneOf');
|
|
expect(result.removedPaths).toContain('allOf');
|
|
expect(result.schema).toEqual({ type: 'object' });
|
|
});
|
|
|
|
test('handles deeply nested structures', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
level1: {
|
|
type: 'object',
|
|
properties: {
|
|
level2: {
|
|
type: 'object',
|
|
uiHint: 'remove-me',
|
|
properties: {
|
|
value: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(1);
|
|
expect(result.removedPaths).toContain('properties.level1.properties.level2.uiHint');
|
|
});
|
|
|
|
test('strips "examples" field (issue #155)', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
command: {
|
|
type: 'string',
|
|
description: 'The command to execute',
|
|
examples: ['ls -la', 'git status'],
|
|
},
|
|
timeout: {
|
|
type: 'number',
|
|
description: 'Timeout in ms',
|
|
examples: [5000, 10000],
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(2);
|
|
expect(result.removedPaths).toContain('properties.command.examples');
|
|
expect(result.removedPaths).toContain('properties.timeout.examples');
|
|
expect(result.schema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
command: { type: 'string', description: 'The command to execute' },
|
|
timeout: { type: 'number', description: 'Timeout in ms' },
|
|
},
|
|
});
|
|
});
|
|
|
|
test('keeps "example" (singular) but strips "examples" (plural)', () => {
|
|
const schema = {
|
|
type: 'string',
|
|
example: 'hello',
|
|
examples: ['hello', 'world'],
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(1);
|
|
expect(result.removedPaths).toContain('examples');
|
|
expect(result.schema).toEqual({ type: 'string', example: 'hello' });
|
|
});
|
|
|
|
test('strips Gemini-unsupported JSON Schema metadata fields', () => {
|
|
const schema = {
|
|
$id: 'https://example.com/schema',
|
|
$schema: 'https://json-schema.org/draft-07/schema#',
|
|
title: 'Test Schema',
|
|
description: 'A test schema',
|
|
type: 'string',
|
|
examples: ['example1', 'example2'],
|
|
deprecated: false,
|
|
readOnly: true,
|
|
$comment: 'Internal comment',
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
// $id, $schema, examples, deprecated, readOnly, $comment = 6 removed
|
|
expect(result.removedCount).toBe(6);
|
|
expect(result.removedPaths).toContain('$id');
|
|
expect(result.removedPaths).toContain('$schema');
|
|
expect(result.removedPaths).toContain('examples');
|
|
expect(result.removedPaths).toContain('deprecated');
|
|
expect(result.removedPaths).toContain('readOnly');
|
|
expect(result.removedPaths).toContain('$comment');
|
|
expect(result.schema).toEqual({
|
|
title: 'Test Schema',
|
|
description: 'A test schema',
|
|
type: 'string',
|
|
});
|
|
});
|
|
|
|
test('strips $defs, $ref, definitions (not supported by Gemini)', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
$defs: {
|
|
address: {
|
|
type: 'object',
|
|
properties: { street: { type: 'string' } },
|
|
},
|
|
},
|
|
properties: {
|
|
home: { $ref: '#/$defs/address' },
|
|
},
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(2);
|
|
expect(result.removedPaths).toContain('$defs');
|
|
expect(result.removedPaths).toContain('properties.home.$ref');
|
|
expect(result.schema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
home: {},
|
|
},
|
|
});
|
|
});
|
|
|
|
test('strips additionalProperties (not supported by Gemini)', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: { name: { type: 'string' } },
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(1);
|
|
expect(result.removedPaths).toContain('additionalProperties');
|
|
});
|
|
|
|
test('strips const, not, if/then/else (not supported by Gemini)', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
const: 'fixed',
|
|
not: { type: 'number' },
|
|
if: { properties: { type: { const: 'foo' } } },
|
|
then: { required: ['foo'] },
|
|
else: { required: ['bar'] },
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(5);
|
|
expect(result.removedPaths).toContain('const');
|
|
expect(result.removedPaths).toContain('not');
|
|
expect(result.removedPaths).toContain('if');
|
|
expect(result.removedPaths).toContain('then');
|
|
expect(result.removedPaths).toContain('else');
|
|
expect(result.schema).toEqual({ type: 'object' });
|
|
});
|
|
|
|
test('strips number/array/object validation fields unsupported by Gemini', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
score: {
|
|
type: 'number',
|
|
exclusiveMinimum: 0,
|
|
exclusiveMaximum: 100,
|
|
multipleOf: 5,
|
|
},
|
|
tags: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
uniqueItems: true,
|
|
contains: { type: 'string' },
|
|
additionalItems: false,
|
|
},
|
|
meta: {
|
|
type: 'object',
|
|
writeOnly: true,
|
|
definitions: { addr: { type: 'string' } },
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedPaths).toContain('properties.score.exclusiveMinimum');
|
|
expect(result.removedPaths).toContain('properties.score.exclusiveMaximum');
|
|
expect(result.removedPaths).toContain('properties.score.multipleOf');
|
|
expect(result.removedPaths).toContain('properties.tags.uniqueItems');
|
|
expect(result.removedPaths).toContain('properties.tags.contains');
|
|
expect(result.removedPaths).toContain('properties.tags.additionalItems');
|
|
expect(result.removedPaths).toContain('properties.meta.writeOnly');
|
|
expect(result.removedPaths).toContain('properties.meta.definitions');
|
|
expect(result.removedCount).toBe(8);
|
|
});
|
|
|
|
test('preserves example/default with complex values without recursion', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
example: ['value1', 'value2'],
|
|
default: { nested: { deep: { unsupportedKey: true } } },
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(0);
|
|
expect(result.schema.example).toEqual(['value1', 'value2']);
|
|
expect(result.schema.default).toEqual({ nested: { deep: { unsupportedKey: true } } });
|
|
});
|
|
|
|
test('handles empty properties and anyOf edge cases', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {},
|
|
anyOf: [],
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(0);
|
|
expect(result.schema.properties).toEqual({});
|
|
expect(result.schema.anyOf).toEqual([]);
|
|
});
|
|
|
|
test('preserves all Gemini-supported fields', () => {
|
|
const schema = {
|
|
type: 'object',
|
|
format: 'date',
|
|
title: 'Test',
|
|
description: 'A test',
|
|
nullable: true,
|
|
example: { name: 'test' },
|
|
default: {},
|
|
enum: ['a', 'b'],
|
|
properties: { name: { type: 'string' } },
|
|
required: ['name'],
|
|
minProperties: 1,
|
|
maxProperties: 10,
|
|
items: { type: 'string' },
|
|
minItems: 0,
|
|
maxItems: 100,
|
|
minLength: 1,
|
|
maxLength: 255,
|
|
pattern: '^[a-z]+$',
|
|
minimum: 0,
|
|
maximum: 100,
|
|
anyOf: [{ type: 'string' }],
|
|
propertyOrdering: ['name'],
|
|
};
|
|
|
|
const result = sanitizeInputSchema(schema);
|
|
|
|
expect(result.removedCount).toBe(0);
|
|
expect(result.removedPaths).toEqual([]);
|
|
});
|
|
|
|
test('handles empty schema', () => {
|
|
const result = sanitizeInputSchema({});
|
|
|
|
expect(result.removedCount).toBe(0);
|
|
expect(result.schema).toEqual({});
|
|
});
|
|
|
|
test('handles null input gracefully', () => {
|
|
const result = sanitizeInputSchema(null as any);
|
|
expect(result.schema).toBeNull();
|
|
expect(result.removedCount).toBe(0);
|
|
});
|
|
|
|
test('handles undefined input gracefully', () => {
|
|
const result = sanitizeInputSchema(undefined as any);
|
|
expect(result.schema).toBeUndefined();
|
|
expect(result.removedCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('sanitizeToolSchemas', () => {
|
|
test('sanitizes multiple tools and tracks per-tool changes', () => {
|
|
const tools = [
|
|
{
|
|
name: 'clean_tool',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: { name: { type: 'string' } },
|
|
},
|
|
},
|
|
{
|
|
name: 'figma_tool',
|
|
input_schema: {
|
|
type: 'object',
|
|
cornerRadius: 8,
|
|
fillColor: '#000',
|
|
properties: { id: { type: 'string' } },
|
|
},
|
|
},
|
|
{
|
|
name: 'design_tool',
|
|
input_schema: {
|
|
type: 'object',
|
|
fontWeight: 'bold',
|
|
properties: { text: { type: 'string' } },
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = sanitizeToolSchemas(tools);
|
|
|
|
expect(result.totalRemoved).toBe(3);
|
|
expect(result.removedByTool).toHaveLength(2);
|
|
expect(result.removedByTool[0].name).toBe('figma_tool');
|
|
expect(result.removedByTool[0].removed).toContain('cornerRadius');
|
|
expect(result.removedByTool[0].removed).toContain('fillColor');
|
|
expect(result.removedByTool[1].name).toBe('design_tool');
|
|
expect(result.removedByTool[1].removed).toContain('fontWeight');
|
|
|
|
// Verify schemas are actually sanitized
|
|
const figmaTool = result.tools.find((t) => t.name === 'figma_tool');
|
|
expect(figmaTool?.input_schema).toEqual({
|
|
type: 'object',
|
|
properties: { id: { type: 'string' } },
|
|
});
|
|
});
|
|
|
|
test('strips examples from Claude Code tool schemas (issue #155)', () => {
|
|
const tools = [
|
|
{
|
|
name: 'Bash',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
command: {
|
|
type: 'string',
|
|
description: 'The command to execute',
|
|
examples: ['ls -la', 'git status', 'npm install'],
|
|
},
|
|
timeout: {
|
|
type: 'number',
|
|
description: 'Timeout in ms',
|
|
examples: [5000, 30000],
|
|
},
|
|
},
|
|
required: ['command'],
|
|
},
|
|
},
|
|
{
|
|
name: 'Read',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
file_path: {
|
|
type: 'string',
|
|
description: 'File path to read',
|
|
examples: ['/src/index.ts', '/package.json'],
|
|
},
|
|
},
|
|
required: ['file_path'],
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = sanitizeToolSchemas(tools);
|
|
|
|
expect(result.totalRemoved).toBe(3);
|
|
expect(result.removedByTool).toHaveLength(2);
|
|
|
|
// Verify examples stripped from Bash tool
|
|
const bashTool = result.tools.find((t) => t.name === 'Bash');
|
|
expect(bashTool?.input_schema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
command: { type: 'string', description: 'The command to execute' },
|
|
timeout: { type: 'number', description: 'Timeout in ms' },
|
|
},
|
|
required: ['command'],
|
|
});
|
|
|
|
// Verify examples stripped from Read tool
|
|
const readTool = result.tools.find((t) => t.name === 'Read');
|
|
expect(readTool?.input_schema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
file_path: { type: 'string', description: 'File path to read' },
|
|
},
|
|
required: ['file_path'],
|
|
});
|
|
});
|
|
|
|
test('handles tools without input_schema', () => {
|
|
const tools = [{ name: 'simple_tool', description: 'No schema' }, { name: 'another_tool' }];
|
|
|
|
const result = sanitizeToolSchemas(tools);
|
|
|
|
expect(result.totalRemoved).toBe(0);
|
|
expect(result.removedByTool).toHaveLength(0);
|
|
expect(result.tools).toEqual(tools);
|
|
});
|
|
|
|
test('returns unmodified tools when all schemas are valid', () => {
|
|
const tools = [
|
|
{
|
|
name: 'valid_tool',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: { value: { type: 'number', minimum: 0 } },
|
|
required: ['value'],
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = sanitizeToolSchemas(tools);
|
|
|
|
expect(result.totalRemoved).toBe(0);
|
|
expect(result.removedByTool).toHaveLength(0);
|
|
});
|
|
|
|
test('handles tools without input_schema (skips null tools)', () => {
|
|
const tools = [
|
|
{ name: 'simple_tool', description: 'No schema' },
|
|
{ name: 'valid', input_schema: { type: 'object' } },
|
|
];
|
|
const result = sanitizeToolSchemas(tools);
|
|
expect(result.tools).toHaveLength(2);
|
|
expect(result.tools[0].name).toBe('simple_tool');
|
|
expect(result.tools[1].name).toBe('valid');
|
|
});
|
|
});
|