1151 lines · 37.1 KB · TypeScript
RawDownload
import { describe, it, expect } from "@effect/vitest";import { Effect, Predicate } from "effect";import { HttpServerResponse } from "effect/unstable/http"; import {  ConnectionId,  CreateConnectionInput,  createExecutor,  definePlugin,  ElicitationResponse,  makeTestConfig,  RemoveSecretInput,  Scope,  ScopeId,  SecretId,  TokenMaterial,} from "@executor-js/sdk";import { memorySecretsPlugin, serveTestHttpApp } from "@executor-js/sdk/testing"; import { graphqlPlugin } from "./plugin";import { endpointForTelemetry } from "./invoke";import { introspect } from "./introspect";import { GraphqlSourceBindingInput, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types";import type { IntrospectionResult } from "./introspect";import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; const TEST_SCOPE = "test-scope"; // ---------------------------------------------------------------------------// Mock introspection response// --------------------------------------------------------------------------- const introspectionResult: IntrospectionResult = {  __schema: {    queryType: { name: "Query" },    mutationType: { name: "Mutation" },    types: [      {        kind: "OBJECT",        name: "Query",        description: null,        fields: [          {            name: "hello",            description: "Say hello",            args: [              {                name: "name",                description: null,                type: { kind: "SCALAR", name: "String", ofType: null },                defaultValue: null,              },            ],            type: { kind: "SCALAR", name: "String", ofType: null },          },        ],        inputFields: null,        enumValues: null,      },      {        kind: "OBJECT",        name: "Mutation",        description: null,        fields: [          {            name: "setGreeting",            description: "Set greeting message",            args: [              {                name: "message",                description: null,                type: {                  kind: "NON_NULL",                  name: null,                  ofType: { kind: "SCALAR", name: "String", ofType: null },                },                defaultValue: null,              },            ],            type: { kind: "SCALAR", name: "String", ofType: null },          },        ],        inputFields: null,        enumValues: null,      },      {        kind: "SCALAR",        name: "String",        description: null,        fields: null,        inputFields: null,        enumValues: null,      },    ],  },}; const introspectionJson = JSON.stringify({ data: introspectionResult });const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });const declineAll = () => Effect.succeed(ElicitationResponse.make({ action: "decline" })); const sampleDataPlugin = definePlugin(() => ({  id: "sample-read-test" as const,  storage: () => ({}),  staticSources: () => [    {      id: "sample",      kind: "in-memory",      name: "Sample",      tools: [        {          name: "read",          description: "Read sample data",          inputSchema: {            type: "object",            properties: {},            additionalProperties: false,          },          handler: () => Effect.succeed("sample-value"),        },      ],    },  ],})); describe("graphqlPlugin real protocol server", () => {  it("uses query-free endpoints for invocation attributes", () => {    expect(endpointForTelemetry("https://api.example.test/graphql?token=secret#section")).toBe(      "https://api.example.test/graphql",    );  });   it.effect("does not include upstream response bodies in introspection status errors", () =>    Effect.gen(function* () {      const server = yield* serveTestHttpApp(() =>        Effect.succeed(          HttpServerResponse.text("internal token value", {            status: 500,            contentType: "text/plain",          }),        ),      );       const error = yield* introspect(server.url("/graphql")).pipe(        Effect.provide(server.httpClientLayer),        Effect.flip,      );       expect(error).toHaveProperty("message", "Introspection failed with status 500");      expect(error).not.toHaveProperty("message", expect.stringContaining("internal token value"));    }),  );   it.effect("adds a source by introspecting the live GraphQL endpoint", () =>    Effect.gen(function* () {      const server = yield* serveGreetingServer;      const executor = yield* createExecutor(        makeTestConfig({ plugins: [graphqlPlugin()] as const }),      );       const result = yield* executor.graphql.addSource({        endpoint: server.endpoint,        scope: TEST_SCOPE,        namespace: "live_graph",      });       expect(result).toEqual({ toolCount: 2, namespace: "live_graph" });       const tools = yield* executor.tools.list();      expect(tools.map((tool) => tool.id)).toEqual(        expect.arrayContaining(["live_graph.query.hello", "live_graph.mutation.setGreeting"]),      );       const requests = yield* server.requests;      expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true);    }),  );   it.effect("invokes a live query with headers and query params", () =>    Effect.gen(function* () {      const server = yield* serveGreetingServer;      const executor = yield* createExecutor(        makeTestConfig({ plugins: [graphqlPlugin()] as const }),      );       yield* executor.graphql.addSource({        endpoint: server.endpoint,        scope: TEST_SCOPE,        namespace: "live_invoke",        headers: { "x-static": "abc" },        queryParams: { token: "qp-token" },      });      yield* server.clearRequests;       const result = yield* executor.tools.invoke("live_invoke.query.hello", {        name: "Ada",      });       expect(result).toEqual({        status: 200,        data: { hello: "Hello Ada" },        errors: null,      });       const requests = yield* server.requests;      expect(requests.length).toBe(1);      expect(requests[0]?.headers["x-static"]).toBe("abc");      expect(new URL(requests[0]!.url).searchParams.get("token")).toBe("qp-token");      expect(requests[0]?.payload.variables).toEqual({ name: "Ada" });    }),  );   it.effect("invokes OAuth-backed sources with a bearer token", () =>    Effect.gen(function* () {      const server = yield* serveGreetingServer;      const executor = yield* createExecutor(        makeTestConfig({          plugins: [memorySecretsPlugin(), graphqlPlugin()] as const,        }),      );       const connectionId = ConnectionId.make("graphql-oauth2-test");      yield* executor.connections.create(        CreateConnectionInput.make({          id: connectionId,          scope: ScopeId.make(TEST_SCOPE),          provider: "oauth2",          identityLabel: "GraphQL Test",          accessToken: TokenMaterial.make({            secretId: SecretId.make(`${connectionId}.access_token`),            name: "GraphQL Access Token",            value: "secret-token",          }),          refreshToken: null,          expiresAt: null,          oauthScope: null,          providerState: null,        }),      );       yield* executor.graphql.addSource({        endpoint: server.endpoint,        scope: TEST_SCOPE,        namespace: "oauth_graph",        credentialTargetScope: TEST_SCOPE,        auth: { kind: "oauth2", connectionId },      });      yield* server.clearRequests;       const result = yield* executor.tools.invoke("oauth_graph.query.hello", {        name: "Ada",      });       expect(result).toEqual({        status: 200,        data: { hello: "Hello Ada" },        errors: null,      });       const requests = yield* server.requests;      expect(requests[0]?.headers.authorization).toBe("Bearer secret-token");    }),  );}); describe("graphqlPlugin", () => {  it.effect("registers tools from introspection JSON", () =>    Effect.gen(function* () {      const executor = yield* createExecutor(        makeTestConfig({ plugins: [graphqlPlugin()] as const }),      );       const result = yield* executor.graphql.addSource({        endpoint: "http://localhost:4000/graphql",        scope: "test-scope",        introspectionJson,        namespace: "test_api",      });      expect(result.toolCount).toBe(2);      expect(result.namespace).toBe("test_api");       const tools = yield* executor.tools.list();      const ids = tools.map((t) => t.id);      expect(ids).toContain("test_api.query.hello");      expect(ids).toContain("test_api.mutation.setGreeting");      // static executor tool also present under the executor namespace      expect(ids).toContain("executor.graphql.addSource");       const queryTool = tools.find((t) => t.id === "test_api.query.hello");      expect(queryTool?.description).toBe("Say hello");       const mutationTool = tools.find((t) => t.id === "test_api.mutation.setGreeting");      expect(mutationTool?.description).toBe("Set greeting message");    }),  );   it.effect("removes a source and its tools", () =>    Effect.gen(function* () {      const executor = yield* createExecutor(        makeTestConfig({ plugins: [graphqlPlugin()] as const }),      );       yield* executor.graphql.addSource({        endpoint: "http://localhost:4000/graphql",        scope: "test-scope",        introspectionJson,        namespace: "removable",      });       let tools = yield* executor.tools.list();      expect(tools.filter((t) => t.sourceId === "removable").length).toBe(2);       yield* executor.graphql.removeSource("removable", TEST_SCOPE);       tools = yield* executor.tools.list();      expect(tools.filter((t) => t.sourceId === "removable").length).toBe(0);       const source = yield* executor.graphql.getSource("removable", TEST_SCOPE);      expect(source).toBeNull();    }),  );   it.effect("lists sources with the executor built-in source", () =>    Effect.gen(function* () {      const executor = yield* createExecutor(        makeTestConfig({ plugins: [graphqlPlugin()] as const }),      );       yield* executor.graphql.addSource({        endpoint: "http://localhost:4000/graphql",        scope: "test-scope",        introspectionJson,        namespace: "my_gql",      });       const sources = yield* executor.sources.list();      const dynamic = sources.find((s) => s.id === "my_gql");      expect(dynamic).toBeDefined();      expect(dynamic!.kind).toBe("graphql");      expect(dynamic!.canRemove).toBe(true);      expect(dynamic!.canEdit).toBe(true);      expect(dynamic!.runtime).toBe(false);       expect(sources.find((s) => s.id === "graphql")).toBeUndefined();      const control = sources.find((s) => s.id === "executor");      expect(control).toBeDefined();      expect(control!.runtime).toBe(true);    }),  );   it.effect("mutations require approval via resolveAnnotations", () =>    Effect.gen(function* () {      const executor = yield* createExecutor(        makeTestConfig({ plugins: [graphqlPlugin()] as const }),      );       yield* executor.graphql.addSource({        endpoint: "http://localhost:4000/graphql",        scope: "test-scope",        introspectionJson,        namespace: "approval_test",      });       const tools = yield* executor.tools.list();      const mutationTool = tools.find((t) => t.id === "approval_test.mutation.setGreeting");      expect(mutationTool).toBeDefined();      expect(mutationTool!.annotations?.requiresApproval).toBe(true);      expect(mutationTool!.annotations?.approvalDescription).toBe("mutation setGreeting");       const queryTool = tools.find((t) => t.id === "approval_test.query.hello");      expect(queryTool).toBeDefined();      expect(queryTool!.annotations?.requiresApproval).toBeFalsy();    }),  );   it.effect("updateSource patches endpoint/headers without re-registering", () =>    Effect.gen(function* () {      const executor = yield* createExecutor(        makeTestConfig({ plugins: [graphqlPlugin()] as const }),      );       yield* executor.graphql.addSource({        endpoint: "http://localhost:4000/graphql",        scope: "test-scope",        introspectionJson,        namespace: "patched",      });       yield* executor.graphql.updateSource("patched", TEST_SCOPE, {        endpoint: "http://localhost:5000/graphql",        headers: { "x-custom": "abc" },      });       const source = yield* executor.graphql.getSource("patched", TEST_SCOPE);      expect(source?.endpoint).toBe("http://localhost:5000/graphql");      expect(source?.headers).toEqual({ "x-custom": "abc" });       // Tools still present (no re-register happened, but they were      // already there from addSource and haven't been removed).      const tools = yield* executor.tools.list();      expect(tools.filter((t) => t.sourceId === "patched").length).toBe(2);    }),  );   it.effect("static executor.graphql.addSource delegates to extension", () =>    Effect.gen(function* () {      const userScope = ScopeId.make("static-user");      const orgScope = ScopeId.make("static-org");      const executor = yield* createExecutor(        makeTestConfig({          scopes: [            Scope.make({ id: userScope, name: "user", createdAt: new Date() }),            Scope.make({ id: orgScope, name: "org", createdAt: new Date() }),          ],          plugins: [graphqlPlugin()] as const,        }),      );       const result = yield* executor.tools.invoke(        "executor.graphql.addSource",        {          scope: String(orgScope),          endpoint: "http://localhost:4000/graphql",          introspectionJson,          namespace: "via_static",        },        { onElicitation: "accept-all" },      );      expect(result).toEqual({ toolCount: 2, namespace: "via_static" });      expect(yield* executor.graphql.getSource("via_static", String(userScope))).toBeNull();      expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe(        orgScope,      );       const tools = yield* executor.tools.list();      expect(tools.filter((t) => t.sourceId === "via_static").length).toBe(2);    }),  );   it.effect("describes static addSource parameters from Standard Schema", () =>    Effect.gen(function* () {      const executor = yield* createExecutor(makeTestConfig({ plugins: [graphqlPlugin()] }));       const schema = yield* executor.tools.schema("executor.graphql.addSource");       expect(schema).not.toBeNull();      expect(schema!.inputTypeScript).toContain("scope: string");      expect(schema!.inputTypeScript).toContain("endpoint: string");      expect(        (schema!.inputSchema as { properties?: Record<string, unknown> }).properties,      ).toHaveProperty("credentialTargetScope");      expect(schema!.inputTypeScript).not.toBe("Record<string, unknown>");    }),  );   it.effect("requires approval before a runtime-added query sends prior tool output", () =>    Effect.gen(function* () {      const server = yield* serveGreetingServer;      const executor = yield* createExecutor(        makeTestConfig({ plugins: [sampleDataPlugin(), graphqlPlugin()] as const }),      );       const trusted = yield* executor.tools.invoke(        "sample.read",        {},        { onElicitation: declineAll },      );      expect(trusted).toBe("sample-value");      const declined = yield* executor.tools        .invoke(          "executor.graphql.addSource",          {            endpoint: server.endpoint,            scope: TEST_SCOPE,            introspectionJson,            namespace: "runtime_graphql",          },          { onElicitation: declineAll },        )        .pipe(Effect.flip);      expect(Predicate.isTagged(declined, "ElicitationDeclinedError")).toBe(true);       const requests = yield* server.requests;      expect(requests.some((request) => request.payload.variables?.name === "sample-value")).toBe(        false,      );    }),  );   it.effect("applies source headers to the introspection request after approval", () =>    Effect.gen(function* () {      const server = yield* serveGreetingServer;      const executor = yield* createExecutor(        makeTestConfig({ plugins: [graphqlPlugin()] as const }),      );       yield* executor.tools.invoke(        "executor.graphql.addSource",        {          endpoint: server.endpoint,

Lines 1–500 of 1151.