/** * 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'); }); });