import { readFileSync } from "node:fs"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import type { OpenApiToolDefinition } from "./openapi-definitions"; import { compileOpenApiToolDefinitions } from "./openapi-definitions"; import { extractOpenApiManifest } from "./openapi-extraction"; import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; const readFixture = (name: string): string => readFileSync( new URL(`../../control-plane/src/runtime/fixtures/${name}`, import.meta.url), "utf8", ); describe("buildOpenApiToolPresentation", () => { it("adds parameter descriptions to flattened call schemas", () => { const definition: OpenApiToolDefinition = { toolId: "accessGroups.deleteProject", rawToolId: "deleteAccessGroupProject", name: "Delete an access group project", description: "Allows deletion of an access group project", group: "accessGroups", leaf: "deleteProject", tags: [], versionSegment: "v1", method: "delete", path: "/v1/access-groups/{accessGroupIdOrName}/projects/{projectId}", invocation: { method: "delete", pathTemplate: "/v1/access-groups/{accessGroupIdOrName}/projects/{projectId}", parameters: [ { name: "accessGroupIdOrName", location: "path", required: true, }, { name: "projectId", location: "path", required: true, }, { name: "teamId", location: "query", required: false, }, { name: "slug", location: "query", required: false, }, ], requestBody: null, }, operationHash: "deadbeefcafebabe", typing: { inputSchema: { type: "object", properties: { accessGroupIdOrName: { type: "string" }, projectId: { type: "string" }, teamId: { type: "string" }, slug: { type: "string" }, }, required: ["accessGroupIdOrName", "projectId"], additionalProperties: false, }, }, documentation: { summary: "Delete an access group project", parameters: [ { name: "accessGroupIdOrName", location: "path", required: true, description: "The access group identifier or slug.", }, { name: "projectId", location: "path", required: true, description: "The project identifier.", }, { name: "teamId", location: "query", required: false, description: "The Team identifier to perform the request on behalf of.", }, { name: "slug", location: "query", required: false, description: "The Team slug to perform the request on behalf of.", }, ], }, }; const presentation = buildOpenApiToolPresentation({ definition }); const schema = presentation.inputSchema as { properties?: Record; }; expect(schema.properties?.accessGroupIdOrName?.description).toBe( "The access group identifier or slug.", ); expect(schema.properties?.projectId?.description).toBe( "The project identifier.", ); expect(schema.properties?.teamId?.description).toBe( "The Team identifier to perform the request on behalf of.", ); expect(schema.properties?.slug?.description).toBe( "The Team slug to perform the request on behalf of.", ); }); it("uses the request body property schema instead of nesting the full input schema", () => { const definition: OpenApiToolDefinition = { toolId: "projects.update", rawToolId: "updateProject", name: "Update project", description: "Updates a project.", group: "projects", leaf: "update", tags: [], versionSegment: "v1", method: "patch", path: "/v1/projects/{projectId}", invocation: { method: "patch", pathTemplate: "/v1/projects/{projectId}", parameters: [ { name: "projectId", location: "path", required: true, }, ], requestBody: { required: true, contentTypes: ["application/json"], }, }, operationHash: "feedfacecafebeef", typing: { inputSchema: { type: "object", properties: { projectId: { type: "string", }, body: { type: "object", properties: { name: { type: "string", }, }, required: ["name"], additionalProperties: false, }, }, required: ["projectId", "body"], additionalProperties: false, }, }, documentation: { parameters: [ { name: "projectId", location: "path", required: true, description: "The project identifier.", }, ], requestBody: { description: "Project update payload.", }, }, }; const presentation = buildOpenApiToolPresentation({ definition }); const schema = presentation.inputSchema as { properties?: Record; }; const body = schema.properties?.body as { description?: string; properties?: Record; }; expect(body.description).toBe("Project update payload."); expect(body.properties?.name).toEqual({ type: "string" }); expect(body.properties?.body).toBeUndefined(); }); it.effect( "resolves request and response schemas from ref hints for the real Neon OpenAPI spec", () => Effect.gen(function* () { const specText = readFixture("neon-openapi.json"); const manifest = yield* extractOpenApiManifest("neon", specText); const definition = compileOpenApiToolDefinitions(manifest).find( (candidate) => candidate.toolId === "apiKey.createApiKey", ); expect(definition).toBeDefined(); const presentation = buildOpenApiToolPresentation({ definition: definition!, refHintTable: manifest.refHintTable, }); expect(presentation.inputTypePreview).toContain("body"); expect(presentation.inputTypePreview).toContain("key_name"); expect(presentation.outputTypePreview).toContain("key"); expect(presentation.inputSchema).toMatchObject({ type: "object", properties: { body: { type: "object", properties: { key_name: { type: "string", }, }, required: ["key_name"], }, }, required: ["body"], }); expect(presentation.outputSchema).toMatchObject({ type: "object", properties: { id: { type: "integer", }, key: { type: "string", }, name: { type: "string", }, }, }); }), 120_000, ); it.effect( "preserves response wrappers for response-only Neon operations", () => Effect.gen(function* () { const specText = readFixture("neon-openapi.json"); const manifest = yield* extractOpenApiManifest("neon", specText); const definition = compileOpenApiToolDefinitions(manifest).find( (candidate) => candidate.toolId === "apiKey.listApiKeys", ); expect(definition).toBeDefined(); const presentation = buildOpenApiToolPresentation({ definition: definition!, refHintTable: manifest.refHintTable, }); expect(presentation.inputTypePreview).toBe("unknown"); expect(presentation.outputTypePreview).toContain("id"); expect(presentation.outputTypePreview).toContain("name"); expect(presentation.outputSchema).toMatchObject({ type: "array", items: { type: "object", properties: { id: { type: "integer", }, name: { type: "string", }, }, }, }); }), 120_000, ); it.effect( "extracts parameter schemas and descriptions for parameter-only Vercel operations", () => Effect.gen(function* () { const specText = readFixture("vercel-openapi.json"); const manifest = yield* extractOpenApiManifest("vercel", specText); const definition = compileOpenApiToolDefinitions(manifest).find( (candidate) => candidate.toolId === "accessGroups.listAccessGroupProjects", ); expect(definition).toBeDefined(); const presentation = buildOpenApiToolPresentation({ definition: definition!, refHintTable: manifest.refHintTable, }); expect(definition?.documentation?.parameters).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "idOrName", description: "The ID or name of the Access Group.", }), expect.objectContaining({ name: "limit", description: "Limit how many access group projects should be returned.", }), ]), ); expect(presentation.inputSchema).toMatchObject({ type: "object", properties: { idOrName: { type: "string", description: "The ID or name of the Access Group.", }, limit: { type: "integer", description: "Limit how many access group projects should be returned.", minimum: 1, maximum: 100, }, next: { type: "string", description: "Continuation cursor to retrieve the next page of results.", }, teamId: { type: "string", description: "The Team identifier to perform the request on behalf of.", }, slug: { type: "string", description: "The Team slug to perform the request on behalf of.", }, }, required: ["idOrName"], }); }), 120_000, ); });