UNPKG

@copilotkit/shared

Version:

<img src="https://github.com/user-attachments/assets/0a6b64d9-e193-4940-a3f6-60334ac34084" alt="banner" style="border-radius: 12px; border: 2px solid #d6d4fa;" />

813 lines (707 loc) 23.4 kB
import { describe, it, expect, vi } from "vitest"; import { z } from "zod"; import { convertJsonSchemaToZodSchema, actionParametersToJsonSchema, jsonSchemaToActionParameters, JSONSchema, } from "../json-schema"; import { zodToJsonSchema } from "zod-to-json-schema"; import { Parameter } from "../../types"; describe("convertJsonSchemaToZodSchema", () => { it("should convert a simple JSON schema to a Zod schema", () => { const jsonSchema = { type: "object", properties: { name: { type: "string" }, age: { type: "number" }, }, required: ["name", "age"], }; const expectedSchema = z.object({ name: z.string(), age: z.number(), }); const result = convertJsonSchemaToZodSchema(jsonSchema, true); const resultSchemaJson = zodToJsonSchema(result); const expectedSchemaJson = zodToJsonSchema(expectedSchema); expect(resultSchemaJson).toStrictEqual(expectedSchemaJson); }); it("should convert a JSON schema with nested objects to a Zod schema", () => { const jsonSchema = { type: "object", properties: { name: { type: "string" }, address: { type: "object", properties: { street: { type: "string" }, city: { type: "string" }, }, required: ["street", "city"], }, }, required: ["name", "address"], }; const expectedSchema = z.object({ name: z.string(), address: z.object({ street: z.string(), city: z.string(), }), }); const result = convertJsonSchemaToZodSchema(jsonSchema, true); const resultSchemaJson = zodToJsonSchema(result); const expectedSchemaJson = zodToJsonSchema(expectedSchema); expect(resultSchemaJson).toStrictEqual(expectedSchemaJson); }); it("should convert a JSON schema with arrays to a Zod schema", () => { const jsonSchema = { type: "object", properties: { names: { type: "array", items: { type: "string" }, }, }, required: ["names"], }; const expectedSchema = z.object({ names: z.array(z.string()), }); const result = convertJsonSchemaToZodSchema(jsonSchema, true); const resultSchemaJson = zodToJsonSchema(result); const expectedSchemaJson = zodToJsonSchema(expectedSchema); expect(resultSchemaJson).toStrictEqual(expectedSchemaJson); }); it("should convert a JSON schema with optional properties to a Zod schema", () => { const jsonSchema = { type: "object", properties: { name: { type: "string" }, age: { type: "number", required: false }, }, }; const expectedSchema = z .object({ name: z.string().optional(), age: z.number().optional(), }) .optional(); const result = convertJsonSchemaToZodSchema(jsonSchema, false); console.log(convertJsonSchemaToZodSchema(jsonSchema, false)); const resultSchemaJson = zodToJsonSchema(result); const expectedSchemaJson = zodToJsonSchema(expectedSchema); expect(resultSchemaJson).toStrictEqual(expectedSchemaJson); }); it("should convert a JSON schema with different types to a Zod schema", () => { const jsonSchema = { type: "object", properties: { name: { type: "string" }, age: { type: "number" }, isAdmin: { type: "boolean" }, }, required: ["name", "age", "isAdmin"], }; const expectedSchema = z.object({ name: z.string(), age: z.number(), isAdmin: z.boolean(), }); const result = convertJsonSchemaToZodSchema(jsonSchema, true); const resultSchemaJson = zodToJsonSchema(result); const expectedSchemaJson = zodToJsonSchema(expectedSchema); expect(resultSchemaJson).toStrictEqual(expectedSchemaJson); }); it("should handle edge case where JSON schema has no properties", () => { const jsonSchema = { type: "object", }; const expectedSchema = z.object({}); const result = convertJsonSchemaToZodSchema(jsonSchema, true); const resultSchemaJson = zodToJsonSchema(result); const expectedSchemaJson = zodToJsonSchema(expectedSchema); expect(resultSchemaJson).toStrictEqual(expectedSchemaJson); }); it("should preserve string enum constraints", () => { const jsonSchema = { type: "object", properties: { status: { type: "string", description: "The status", enum: ["todo", "done"], }, }, required: ["status"], }; const result = convertJsonSchemaToZodSchema(jsonSchema, true); const statusSchema = (result as z.ZodObject<any>).shape.status; expect(statusSchema._def.typeName).toBe("ZodEnum"); expect(statusSchema._def.values).toEqual(["todo", "done"]); }); it("should handle null-union type ['string', 'null'] as nullable", () => { const jsonSchema = { type: "object", properties: { nickname: { type: ["string", "null"], description: "Optional nickname", }, }, required: ["nickname"], }; const result = convertJsonSchemaToZodSchema(jsonSchema, true); const shape = (result as z.ZodObject<any>).shape; // The nickname field should accept both string and null expect(shape.nickname.safeParse("hello").success).toBe(true); expect(shape.nickname.safeParse(null).success).toBe(true); expect(shape.nickname.safeParse(42).success).toBe(false); }); it("should handle null-union type ['number', 'null'] as nullable number", () => { const jsonSchema = { type: "object", properties: { score: { type: ["number", "null"], }, }, required: ["score"], }; const result = convertJsonSchemaToZodSchema(jsonSchema, true); const shape = (result as z.ZodObject<any>).shape; expect(shape.score.safeParse(42).success).toBe(true); expect(shape.score.safeParse(null).success).toBe(true); expect(shape.score.safeParse("hello").success).toBe(false); }); it("should handle edge case where JSON schema has no required properties", () => { const jsonSchema = { type: "object", properties: { name: { type: "string" }, age: { type: "number" }, }, }; const expectedSchema = z .object({ name: z.string().optional(), age: z.number().optional(), }) .optional(); const result = convertJsonSchemaToZodSchema(jsonSchema, false); const resultSchemaJson = zodToJsonSchema(result); const expectedSchemaJson = zodToJsonSchema(expectedSchema); expect(resultSchemaJson).toStrictEqual(expectedSchemaJson); }); it("should resolve non-circular $ref definitions correctly", () => { const jsonSchema = { type: "object", properties: { address: { $ref: "#/$defs/Address" }, }, required: ["address"], $defs: { Address: { type: "object", properties: { street: { type: "string", description: "Street name" }, city: { type: "string", description: "City name" }, }, required: ["street", "city"], description: "A postal address", }, }, }; const result = convertJsonSchemaToZodSchema(jsonSchema, true); const resultJson = zodToJsonSchema(result); const expectedSchema = z.object({ address: z .object({ street: z.string().describe("Street name"), city: z.string().describe("City name"), }) .describe("A postal address"), }); const expectedJson = zodToJsonSchema(expectedSchema); expect(resultJson).toStrictEqual(expectedJson); }); it("should handle circular $ref without crashing and return z.any()", () => { // A schema where Node references itself — this would cause infinite // recursion without cycle detection. const jsonSchema = { type: "object", properties: { root: { $ref: "#/$defs/Node" }, }, required: ["root"], $defs: { Node: { type: "object", properties: { value: { type: "string", description: "Node value" }, child: { $ref: "#/$defs/Node" }, }, required: ["value"], description: "A tree node", }, }, }; const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); // Must not throw or hang const result = convertJsonSchemaToZodSchema(jsonSchema, true); expect(result).toBeDefined(); // The circular ref should have produced a console.warn expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Circular $ref detected"), ); // The top-level shape should still have a "root" key that is an object const shape = (result as z.ZodObject<any>).shape; expect(shape.root).toBeDefined(); // Inside root, "value" should be a string and "child" should be z.any() // (child is optional since it's not in required[], so unwrap ZodOptional) const rootShape = (shape.root as z.ZodObject<any>).shape; expect(rootShape.value._def.typeName).toBe("ZodString"); const childDef = rootShape.child._def; if (childDef.typeName === "ZodOptional") { expect(childDef.innerType._def.typeName).toBe("ZodAny"); } else { expect(childDef.typeName).toBe("ZodAny"); } warnSpy.mockRestore(); }); it("should resolve the same $ref used by multiple sibling properties", () => { // Two properties reference the same $def — the visited set must NOT // mark the second usage as circular. const jsonSchema = { type: "object", properties: { billing: { $ref: "#/$defs/Address" }, shipping: { $ref: "#/$defs/Address" }, }, required: ["billing", "shipping"], $defs: { Address: { type: "object", properties: { street: { type: "string", description: "Street" }, city: { type: "string", description: "City" }, }, required: ["street", "city"], description: "An address", }, }, }; const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = convertJsonSchemaToZodSchema(jsonSchema, true); const shape = (result as z.ZodObject<any>).shape; // Both should be fully resolved objects, NOT z.any() expect(shape.billing._def.typeName).toBe("ZodObject"); expect(shape.shipping._def.typeName).toBe("ZodObject"); // No circular-ref warning should have been emitted expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); it("should resolve a shared $ref used in different branches of a $ref chain", () => { // Wrapper -> Container (via $ref) which has two children both using $ref to Leaf. // Without set cloning, the second Leaf ref in Container would be wrongly flagged // as circular because the first Leaf resolution already added it to the set. const jsonSchema = { type: "object", properties: { wrapper: { $ref: "#/$defs/Container" }, }, required: ["wrapper"], $defs: { Container: { type: "object", properties: { first: { $ref: "#/$defs/Leaf" }, second: { $ref: "#/$defs/Leaf" }, }, required: ["first", "second"], description: "A container with two leaves", }, Leaf: { type: "object", properties: { label: { type: "string", description: "Leaf label" }, }, required: ["label"], description: "A leaf node", }, }, }; const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = convertJsonSchemaToZodSchema(jsonSchema, true); const wrapperShape = ( (result as z.ZodObject<any>).shape.wrapper as z.ZodObject<any> ).shape; // Both first and second should be fully resolved Leaf objects expect(wrapperShape.first._def.typeName).toBe("ZodObject"); expect(wrapperShape.second._def.typeName).toBe("ZodObject"); // No circular-ref warning should have been emitted expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); it("should handle anyOf with $ref variants", () => { const jsonSchema = { type: "object", properties: { pet: { anyOf: [{ $ref: "#/$defs/Cat" }, { $ref: "#/$defs/Dog" }], description: "A pet", }, }, required: ["pet"], $defs: { Cat: { type: "object", properties: { meow: { type: "boolean" } }, required: ["meow"], }, Dog: { type: "object", properties: { bark: { type: "boolean" } }, required: ["bark"], }, }, }; const result = convertJsonSchemaToZodSchema(jsonSchema, true); expect(result).toBeDefined(); // Should produce a union inside the "pet" property const petSchema = (result as z.ZodObject<any>).shape.pet; expect(petSchema._def.typeName).toBe("ZodUnion"); }); it("should handle integer type as z.number()", () => { const jsonSchema = { type: "object", properties: { count: { type: "integer", description: "A count" }, }, required: ["count"], }; const result = convertJsonSchemaToZodSchema(jsonSchema, true); const shape = (result as z.ZodObject<any>).shape; expect(shape.count._def.typeName).toBe("ZodNumber"); }); it("should handle null type", () => { const jsonSchema = { type: "object", properties: { empty: { type: "null", description: "Always null" }, }, required: ["empty"], }; const result = convertJsonSchemaToZodSchema(jsonSchema, true); const shape = (result as z.ZodObject<any>).shape; expect(shape.empty._def.typeName).toBe("ZodNull"); }); it("should warn and return z.any() for unsupported schema types", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const jsonSchema = { type: "custom_unsupported" }; const result = convertJsonSchemaToZodSchema(jsonSchema, true); expect(result._def.typeName).toBe("ZodAny"); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( 'Unsupported JSON schema type "custom_unsupported"', ), ); warnSpy.mockRestore(); }); }); describe("jsonSchemaToActionParameters", () => { it("should convert a simple JSONSchema to Parameter array", () => { const jsonSchema: JSONSchema = { type: "object", properties: { name: { type: "string", description: "User name" }, age: { type: "number", description: "User age" }, }, required: ["name"], }; const expectedParameters: Parameter[] = [ { name: "name", type: "string", description: "User name" }, { name: "age", type: "number", description: "User age", required: false }, ]; const result = jsonSchemaToActionParameters(jsonSchema); expect(result).toEqual(expectedParameters); }); it("should convert JSONSchema with enum to Parameter array", () => { const jsonSchema: JSONSchema = { type: "object", properties: { role: { type: "string", enum: ["admin", "user", "guest"], description: "User role", }, }, required: ["role"], }; const expectedParameters: Parameter[] = [ { name: "role", type: "string", enum: ["admin", "user", "guest"], description: "User role", }, ]; const result = jsonSchemaToActionParameters(jsonSchema); expect(result).toEqual(expectedParameters); }); it("should convert nested object JSONSchema to Parameter array", () => { const jsonSchema: JSONSchema = { type: "object", properties: { user: { type: "object", properties: { name: { type: "string", description: "User name" }, age: { type: "number", description: "User age" }, }, required: ["name"], description: "User information", }, }, required: ["user"], }; const expectedParameters: Parameter[] = [ { name: "user", type: "object", description: "User information", attributes: [ { name: "name", type: "string", description: "User name" }, { name: "age", type: "number", description: "User age", required: false, }, ], }, ]; const result = jsonSchemaToActionParameters(jsonSchema); expect(result).toEqual(expectedParameters); }); it("should convert array JSONSchema to Parameter array", () => { const jsonSchema: JSONSchema = { type: "object", properties: { tags: { type: "array", items: { type: "string" }, description: "User tags", }, }, required: ["tags"], }; const expectedParameters: Parameter[] = [ { name: "tags", type: "string[]", description: "User tags" }, ]; const result = jsonSchemaToActionParameters(jsonSchema); expect(result).toEqual(expectedParameters); }); it("should convert object array JSONSchema to Parameter array", () => { const jsonSchema: JSONSchema = { type: "object", properties: { addresses: { type: "array", items: { type: "object", properties: { street: { type: "string", description: "Street name" }, city: { type: "string", description: "City name" }, }, required: ["street"], }, description: "User addresses", }, }, required: ["addresses"], }; const expectedParameters: Parameter[] = [ { name: "addresses", type: "object[]", description: "User addresses", attributes: [ { name: "street", type: "string", description: "Street name" }, { name: "city", type: "string", description: "City name", required: false, }, ], }, ]; const result = jsonSchemaToActionParameters(jsonSchema); expect(result).toEqual(expectedParameters); }); it("should handle boolean types", () => { const jsonSchema: JSONSchema = { type: "object", properties: { isAdmin: { type: "boolean", description: "Is user an admin" }, }, required: ["isAdmin"], }; const expectedParameters: Parameter[] = [ { name: "isAdmin", type: "boolean", description: "Is user an admin" }, ]; const result = jsonSchemaToActionParameters(jsonSchema); expect(result).toEqual(expectedParameters); }); it("should handle empty object schema", () => { const jsonSchema: JSONSchema = { type: "object", }; const expectedParameters: Parameter[] = []; const result = jsonSchemaToActionParameters(jsonSchema); expect(result).toEqual(expectedParameters); }); it("should throw error for nested arrays", () => { const jsonSchema: JSONSchema = { type: "object", properties: { nestedArray: { type: "array", items: { type: "array", items: { type: "string" }, }, description: "Matrix of strings", }, }, required: ["nestedArray"], }; expect(() => jsonSchemaToActionParameters(jsonSchema)).toThrow( "Nested arrays are not supported", ); }); it("should handle null-union type ['string', 'null'] as optional string", () => { const jsonSchema = { type: "object", properties: { nickname: { type: ["string", "null"], description: "Optional nickname", }, }, required: ["nickname"], }; const result = jsonSchemaToActionParameters(jsonSchema as any); expect(result).toEqual([ { name: "nickname", type: "string", description: "Optional nickname", required: false, }, ]); }); it("should handle null-union type ['object', 'null'] preserving properties", () => { const jsonSchema = { type: "object", properties: { metadata: { type: ["object", "null"], description: "Optional metadata", properties: { key: { type: "string" }, }, required: ["key"], }, }, required: ["metadata"], }; const result = jsonSchemaToActionParameters(jsonSchema as any); expect(result).toEqual([ { name: "metadata", type: "object", description: "Optional metadata", required: false, attributes: [{ name: "key", type: "string", description: undefined }], }, ]); }); it("should handle null-union type when field is already optional", () => { const jsonSchema = { type: "object", properties: { nickname: { type: ["string", "null"], description: "Optional nickname", }, }, // nickname is NOT in required }; const result = jsonSchemaToActionParameters(jsonSchema as any); expect(result).toEqual([ { name: "nickname", type: "string", description: "Optional nickname", required: false, }, ]); }); it("should handle type array with only 'null' by falling back to string", () => { const jsonSchema = { type: "object", properties: { weird: { type: ["null"], description: "Null-only type", }, }, required: ["weird"], }; const result = jsonSchemaToActionParameters(jsonSchema as any); expect(result).toEqual([ { name: "weird", type: "string", description: "Null-only type", required: false, }, ]); }); it("should ensure round-trip conversion works", () => { const originalParameters: Parameter[] = [ { name: "name", type: "string", description: "User name" }, { name: "age", type: "number", description: "User age", required: false }, { name: "role", type: "string", enum: ["admin", "user"], description: "User role", }, { name: "address", type: "object", description: "User address", attributes: [ { name: "street", type: "string", description: "Street name" }, { name: "city", type: "string", description: "City name" }, ], }, { name: "contacts", type: "object[]", description: "User contacts", attributes: [ { name: "type", type: "string", description: "Contact type" }, { name: "value", type: "string", description: "Contact value" }, ], }, ]; const jsonSchema = actionParametersToJsonSchema(originalParameters); const roundTripParameters = jsonSchemaToActionParameters(jsonSchema); expect(roundTripParameters).toEqual(originalParameters); }); });