Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
759 lines
24 KiB
TypeScript
759 lines
24 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
executeGraphqlOperation,
|
|
importGraphqlSchema,
|
|
prepareGraphqlInstall,
|
|
} from "./graphql-connectors.js";
|
|
import { InstalledConnectorProvider } from "./installed-connectors.js";
|
|
|
|
const STAR_WARS_INTROSPECTION = {
|
|
data: {
|
|
__schema: {
|
|
queryType: { name: "Query" },
|
|
mutationType: { name: "Mutation" },
|
|
types: [
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Query",
|
|
fields: [
|
|
{
|
|
name: "hero",
|
|
description: "Fetch a hero",
|
|
args: [
|
|
{
|
|
name: "episode",
|
|
type: { kind: "ENUM", name: "Episode", ofType: null },
|
|
defaultValue: null,
|
|
},
|
|
],
|
|
type: { kind: "OBJECT", name: "Character", ofType: null },
|
|
},
|
|
{
|
|
name: "hello",
|
|
description: "Plain hello",
|
|
args: [],
|
|
type: { kind: "SCALAR", name: "String", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Mutation",
|
|
fields: [
|
|
{
|
|
name: "createNote",
|
|
description: "Create a note",
|
|
args: [
|
|
{
|
|
name: "text",
|
|
type: {
|
|
kind: "NON_NULL",
|
|
name: null,
|
|
ofType: { kind: "SCALAR", name: "String", ofType: null },
|
|
},
|
|
defaultValue: null,
|
|
},
|
|
],
|
|
type: { kind: "SCALAR", name: "String", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Character",
|
|
fields: [
|
|
{
|
|
name: "name",
|
|
args: [],
|
|
type: { kind: "SCALAR", name: "String", ofType: null },
|
|
},
|
|
{
|
|
name: "appearsIn",
|
|
args: [],
|
|
type: {
|
|
kind: "LIST",
|
|
name: null,
|
|
ofType: { kind: "ENUM", name: "Episode", ofType: null },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "ENUM",
|
|
name: "Episode",
|
|
enumValues: [{ name: "NEWHOPE" }, { name: "EMPIRE" }, { name: "JEDI" }],
|
|
},
|
|
{ kind: "SCALAR", name: "String" },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
describe("GraphQL connector import", () => {
|
|
it("maps introspection fields into bounded agent tools", () => {
|
|
const operations = importGraphqlSchema(STAR_WARS_INTROSPECTION);
|
|
expect(operations.map((operation) => operation.id)).toEqual([
|
|
"query_hero",
|
|
"query_hello",
|
|
"mutation_createNote",
|
|
]);
|
|
const hero = operations[0]!;
|
|
expect(hero.readOnly).toBe(true);
|
|
expect(hero.variableTypes.episode).toBe("Episode");
|
|
expect(hero.selection).toContain("name");
|
|
expect(hero.selection).toContain("appearsIn");
|
|
expect(operations[2]!.readOnly).toBe(false);
|
|
expect(operations[2]!.inputSchema).toMatchObject({
|
|
type: "object",
|
|
required: ["text"],
|
|
});
|
|
});
|
|
|
|
it("introspects a GraphQL endpoint over the SSRF-safe fetch path", async () => {
|
|
const fetch = vi.fn(async () =>
|
|
Response.json(STAR_WARS_INTROSPECTION, {
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
const prepared = await prepareGraphqlInstall({
|
|
source: "https://graphql.example.test/graphql",
|
|
config: { auth: { type: "none" } },
|
|
remote: {
|
|
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
});
|
|
expect(prepared.operationCount).toBe(3);
|
|
expect(prepared.source).toBe("https://graphql.example.test/graphql");
|
|
expect(fetch).toHaveBeenCalledOnce();
|
|
expect(JSON.stringify(fetch.mock.calls)).toContain("__schema");
|
|
});
|
|
|
|
it.each(["host", "content-length", "connection"])(
|
|
"refuses transport auth header %s",
|
|
async (name) => {
|
|
const fetch = vi.fn();
|
|
await expect(
|
|
prepareGraphqlInstall({
|
|
source: "https://graphql.example.test/graphql",
|
|
config: { auth: { type: "header", name } },
|
|
credential: "test-credential",
|
|
remote: { fetch },
|
|
}),
|
|
).rejects.toThrow(/Transport-level/);
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("rejects invalid public header names before introspection", async () => {
|
|
const fetch = vi.fn();
|
|
await expect(
|
|
prepareGraphqlInstall({
|
|
source: "https://graphql.example.test/graphql",
|
|
config: { headers: { "bad header": "value" } },
|
|
remote: { fetch },
|
|
}),
|
|
).rejects.toThrow();
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows omission of defaulted non-null root arguments", () => {
|
|
const schema = structuredClone(STAR_WARS_INTROSPECTION);
|
|
const arg = schema.data.__schema.types[1]!.fields![0]!.args[0]!;
|
|
Object.assign(arg, { defaultValue: '"default text"' });
|
|
const operation = importGraphqlSchema(schema).find(
|
|
(item) => item.id === "mutation_createNote",
|
|
)!;
|
|
expect(operation.variableTypes.text).toBe("String");
|
|
expect(operation.inputSchema.required).toBeUndefined();
|
|
});
|
|
|
|
it("refuses private GraphQL endpoints during install", async () => {
|
|
await expect(
|
|
prepareGraphqlInstall({
|
|
source: "https://127.0.0.1/graphql",
|
|
config: { auth: { type: "none" } },
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("refuses credentials embedded in a GraphQL endpoint URL", async () => {
|
|
await expect(
|
|
prepareGraphqlInstall({
|
|
source: "https://graphql.example.test/graphql?access_token=secret",
|
|
config: { auth: { type: "none" } },
|
|
}),
|
|
).rejects.toThrow(/encrypted credential field/);
|
|
});
|
|
|
|
it("skips nested fields that require arguments so generated documents stay valid", async () => {
|
|
const operations = importGraphqlSchema({
|
|
data: {
|
|
__schema: {
|
|
queryType: { name: "Query" },
|
|
mutationType: null,
|
|
types: [
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Query",
|
|
fields: [
|
|
{
|
|
name: "viewer",
|
|
args: [],
|
|
type: { kind: "OBJECT", name: "User", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "OBJECT",
|
|
name: "User",
|
|
fields: [
|
|
{
|
|
name: "id",
|
|
args: [],
|
|
type: { kind: "SCALAR", name: "ID", ofType: null },
|
|
},
|
|
{
|
|
name: "status",
|
|
args: [
|
|
{
|
|
name: "includePrivate",
|
|
type: { kind: "SCALAR", name: "Boolean", ofType: null },
|
|
defaultValue: "false",
|
|
},
|
|
],
|
|
type: { kind: "SCALAR", name: "String", ofType: null },
|
|
},
|
|
{
|
|
name: "repository",
|
|
args: [
|
|
{
|
|
name: "name",
|
|
type: {
|
|
kind: "NON_NULL",
|
|
name: null,
|
|
ofType: { kind: "SCALAR", name: "String", ofType: null },
|
|
},
|
|
defaultValue: null,
|
|
},
|
|
],
|
|
type: { kind: "OBJECT", name: "Repository", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Repository",
|
|
fields: [
|
|
{
|
|
name: "name",
|
|
args: [],
|
|
type: { kind: "SCALAR", name: "String", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{ kind: "SCALAR", name: "ID" },
|
|
{ kind: "SCALAR", name: "String" },
|
|
{ kind: "SCALAR", name: "Boolean" },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
const viewer = operations[0]!;
|
|
expect(viewer.selection).toContain("id");
|
|
expect(viewer.selection).toContain("status");
|
|
expect(viewer.selection).not.toContain("repository");
|
|
|
|
let sentQuery = "";
|
|
await executeGraphqlOperation(
|
|
"https://graphql.example.test/graphql",
|
|
{ auth: { type: "none" }, headers: {}, operations: [viewer] },
|
|
viewer,
|
|
{},
|
|
undefined,
|
|
new AbortController().signal,
|
|
{
|
|
fetch: async (_input, init) => {
|
|
sentQuery = String(JSON.parse(String(init?.body)).query);
|
|
return Response.json({ data: { viewer: { id: "1", status: "ok" } } });
|
|
},
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
);
|
|
expect(sentQuery).toMatch(/viewer\s*\{\s*id\s+status\s*\}/);
|
|
expect(sentQuery).not.toMatch(/repository\s*(\{|$)/);
|
|
expect(sentQuery).not.toContain("repository(");
|
|
});
|
|
|
|
it("keeps mutations when a large query catalog would otherwise fill the cap", () => {
|
|
const queryFields = Array.from({ length: 100 }, (_, index) => ({
|
|
name: `queryField${index}`,
|
|
args: [],
|
|
type: { kind: "SCALAR", name: "String", ofType: null },
|
|
}));
|
|
const operations = importGraphqlSchema({
|
|
data: {
|
|
__schema: {
|
|
queryType: { name: "Query" },
|
|
mutationType: { name: "Mutation" },
|
|
types: [
|
|
{ kind: "OBJECT", name: "Query", fields: queryFields },
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Mutation",
|
|
fields: [
|
|
{
|
|
name: "doThing",
|
|
args: [],
|
|
type: { kind: "SCALAR", name: "String", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{ kind: "SCALAR", name: "String" },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
expect(operations.length).toBeLessThanOrEqual(100);
|
|
expect(operations.some((operation) => operation.id === "mutation_doThing")).toBe(true);
|
|
expect(operations.some((operation) => operation.operationType === "query")).toBe(true);
|
|
});
|
|
|
|
it("selects bounded payload fields from union return types", async () => {
|
|
const operations = importGraphqlSchema({
|
|
data: {
|
|
__schema: {
|
|
queryType: { name: "Query" },
|
|
mutationType: null,
|
|
types: [
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Query",
|
|
fields: [
|
|
{
|
|
name: "search",
|
|
args: [],
|
|
type: { kind: "UNION", name: "SearchResult", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "UNION",
|
|
name: "SearchResult",
|
|
possibleTypes: [
|
|
{ kind: "OBJECT", name: "Person" },
|
|
{ kind: "OBJECT", name: "Repository" },
|
|
],
|
|
},
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Person",
|
|
fields: [
|
|
{
|
|
name: "name",
|
|
args: [],
|
|
type: { kind: "SCALAR", name: "String", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Repository",
|
|
fields: [
|
|
{
|
|
name: "stars",
|
|
args: [],
|
|
type: { kind: "SCALAR", name: "Int", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{ kind: "SCALAR", name: "String" },
|
|
{ kind: "SCALAR", name: "Int" },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
const search = operations[0]!;
|
|
expect(search.selection).toBe("__typename ... on Person { name } ... on Repository { stars }");
|
|
|
|
let sentQuery = "";
|
|
await executeGraphqlOperation(
|
|
"https://graphql.example.test/graphql",
|
|
{ auth: { type: "none" }, headers: {}, operations: [search] },
|
|
search,
|
|
{},
|
|
undefined,
|
|
new AbortController().signal,
|
|
{
|
|
fetch: async (_input, init) => {
|
|
sentQuery = String(JSON.parse(String(init?.body)).query);
|
|
return Response.json({
|
|
data: { search: { __typename: "Person", name: "Ada" } },
|
|
});
|
|
},
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
);
|
|
expect(sentQuery).toContain("... on Person { name }");
|
|
expect(sentQuery).toContain("... on Repository { stars }");
|
|
});
|
|
|
|
it("keeps a broad nested union inside the persisted selection budget", () => {
|
|
const scalarFields = Array.from({ length: 300 }, (_, index) => ({
|
|
name: `field_${index.toString().padStart(4, "0")}`,
|
|
args: [],
|
|
type: { kind: "SCALAR", name: "String", ofType: null },
|
|
}));
|
|
const unionMembers = Array.from({ length: 150 }, (_, index) => ({
|
|
kind: "OBJECT",
|
|
name: `Result${index}`,
|
|
}));
|
|
const operations = importGraphqlSchema({
|
|
data: {
|
|
__schema: {
|
|
queryType: { name: "Query" },
|
|
mutationType: null,
|
|
types: [
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Query",
|
|
fields: [
|
|
{
|
|
name: "viewer",
|
|
args: [],
|
|
type: { kind: "OBJECT", name: "Envelope", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "OBJECT",
|
|
name: "Envelope",
|
|
fields: [
|
|
...scalarFields,
|
|
{
|
|
name: "result",
|
|
args: [],
|
|
type: { kind: "UNION", name: "SearchResult", ofType: null },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "UNION",
|
|
name: "SearchResult",
|
|
possibleTypes: unionMembers,
|
|
},
|
|
...unionMembers.map((member) => ({
|
|
...member,
|
|
fields: [
|
|
{
|
|
name: "value",
|
|
args: [],
|
|
type: { kind: "SCALAR", name: "String", ofType: null },
|
|
},
|
|
],
|
|
})),
|
|
{ kind: "SCALAR", name: "String" },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(operations[0]!.selection.length).toBeLessThanOrEqual(6_000);
|
|
expect(operations[0]!.selection).not.toContain("result {");
|
|
});
|
|
});
|
|
|
|
describe("GraphQL execution failures", () => {
|
|
const helloOperation = importGraphqlSchema(STAR_WARS_INTROSPECTION).find(
|
|
(operation) => operation.id === "query_hello",
|
|
)!;
|
|
|
|
function graphqlInstall() {
|
|
return {
|
|
id: "graphql-1",
|
|
kind: "graphql",
|
|
name: "Example GraphQL",
|
|
source: "https://graphql.example.test/graphql",
|
|
secretId: null,
|
|
createdAt: new Date(0),
|
|
config: {
|
|
auth: { type: "none" },
|
|
headers: {},
|
|
operations: [helloOperation],
|
|
},
|
|
};
|
|
}
|
|
|
|
async function executeGraphqlProvider(fetchImpl: typeof globalThis.fetch) {
|
|
const install = graphqlInstall();
|
|
const prisma = {
|
|
capabilityInstall: {
|
|
findMany: vi.fn().mockResolvedValue([install]),
|
|
findFirst: vi.fn().mockResolvedValue(install),
|
|
},
|
|
};
|
|
const provider = new InstalledConnectorProvider(prisma as never, {} as never, {
|
|
fetch: fetchImpl,
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
});
|
|
const context = {
|
|
spaceId: "space-1",
|
|
userId: "user-1",
|
|
signal: new AbortController().signal,
|
|
} as never;
|
|
const tools = await provider.discoverTools(context);
|
|
const tool = tools.find((candidate) => candidate.name === "hello") ?? tools[0]!;
|
|
const events = [];
|
|
for await (const event of provider.execute(
|
|
{
|
|
tool: tool.name,
|
|
args: {},
|
|
executionId: "graphql-fail",
|
|
route: tool.route,
|
|
},
|
|
context,
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
return events;
|
|
}
|
|
|
|
it("throws when the GraphQL response includes protocol errors", async () => {
|
|
const operation = importGraphqlSchema(STAR_WARS_INTROSPECTION)[0]!;
|
|
await expect(
|
|
executeGraphqlOperation(
|
|
"https://graphql.example.test/graphql",
|
|
{ auth: { type: "none" }, headers: {}, operations: [operation] },
|
|
operation,
|
|
{},
|
|
undefined,
|
|
new AbortController().signal,
|
|
{
|
|
fetch: async () => Response.json({ errors: [{ message: "Field 'hero' is required" }] }),
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
),
|
|
).rejects.toThrow(/Field 'hero' is required/);
|
|
});
|
|
|
|
it("throws when the GraphQL HTTP response is not successful", async () => {
|
|
const operation = importGraphqlSchema(STAR_WARS_INTROSPECTION)[0]!;
|
|
await expect(
|
|
executeGraphqlOperation(
|
|
"https://graphql.example.test/graphql",
|
|
{ auth: { type: "none" }, headers: {}, operations: [operation] },
|
|
operation,
|
|
{},
|
|
undefined,
|
|
new AbortController().signal,
|
|
{
|
|
fetch: async () =>
|
|
new Response("nope", { status: 500, headers: { "content-type": "text/plain" } }),
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
),
|
|
).rejects.toThrow(/HTTP 500/);
|
|
});
|
|
|
|
it("throws when a successful GraphQL HTTP response is not JSON", async () => {
|
|
const operation = importGraphqlSchema(STAR_WARS_INTROSPECTION)[0]!;
|
|
await expect(
|
|
executeGraphqlOperation(
|
|
"https://graphql.example.test/graphql",
|
|
{ auth: { type: "none" }, headers: {}, operations: [operation] },
|
|
operation,
|
|
{},
|
|
undefined,
|
|
new AbortController().signal,
|
|
{
|
|
fetch: async () =>
|
|
new Response("<html>upstream unavailable</html>", {
|
|
status: 200,
|
|
headers: { "content-type": "text/html" },
|
|
}),
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
),
|
|
).rejects.toThrow(/not valid JSON/);
|
|
});
|
|
|
|
it("throws when a successful GraphQL HTTP response is not a JSON object", async () => {
|
|
const operation = importGraphqlSchema(STAR_WARS_INTROSPECTION)[0]!;
|
|
await expect(
|
|
executeGraphqlOperation(
|
|
"https://graphql.example.test/graphql",
|
|
{ auth: { type: "none" }, headers: {}, operations: [operation] },
|
|
operation,
|
|
{},
|
|
undefined,
|
|
new AbortController().signal,
|
|
{
|
|
fetch: async () => Response.json("temporarily unavailable"),
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
),
|
|
).rejects.toThrow(/not a JSON object/);
|
|
});
|
|
|
|
it("preserves a bounded result for oversized successful responses", async () => {
|
|
const operation = importGraphqlSchema(STAR_WARS_INTROSPECTION)[0]!;
|
|
const result = await executeGraphqlOperation(
|
|
"https://graphql.example.test/graphql",
|
|
{ auth: { type: "none" }, headers: {}, operations: [operation] },
|
|
operation,
|
|
{},
|
|
undefined,
|
|
new AbortController().signal,
|
|
{
|
|
fetch: async () => Response.json({ data: { value: "x".repeat(1_000_000) } }),
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({ status: 200, truncated: true });
|
|
expect((result as { data: unknown }).data).toEqual(expect.any(String));
|
|
expect((result as { data: string }).data.length).toBeLessThanOrEqual(1_000_000);
|
|
});
|
|
|
|
it("rejects malformed responses that exceed the result limit", async () => {
|
|
const operation = importGraphqlSchema(STAR_WARS_INTROSPECTION)[0]!;
|
|
await expect(
|
|
executeGraphqlOperation(
|
|
"https://graphql.example.test/graphql",
|
|
{ auth: { type: "none" }, headers: {}, operations: [operation] },
|
|
operation,
|
|
{},
|
|
undefined,
|
|
new AbortController().signal,
|
|
{
|
|
fetch: async () =>
|
|
new Response(`<html>${"x".repeat(1_000_000)}</html>`, {
|
|
status: 200,
|
|
headers: { "content-type": "text/html" },
|
|
}),
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
),
|
|
).rejects.toThrow(/not valid JSON/);
|
|
});
|
|
|
|
it("rejects GraphQL errors that exceed the result limit", async () => {
|
|
const operation = importGraphqlSchema(STAR_WARS_INTROSPECTION)[0]!;
|
|
await expect(
|
|
executeGraphqlOperation(
|
|
"https://graphql.example.test/graphql",
|
|
{ auth: { type: "none" }, headers: {}, operations: [operation] },
|
|
operation,
|
|
{},
|
|
undefined,
|
|
new AbortController().signal,
|
|
{
|
|
fetch: async () =>
|
|
Response.json({
|
|
errors: [{ message: "Oversized operation failed" }],
|
|
padding: "x".repeat(1_000_000),
|
|
}),
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
),
|
|
).rejects.toThrow(/Oversized operation failed/);
|
|
});
|
|
|
|
it("rejects responses that exceed the validation limit", async () => {
|
|
const operation = importGraphqlSchema(STAR_WARS_INTROSPECTION)[0]!;
|
|
await expect(
|
|
executeGraphqlOperation(
|
|
"https://graphql.example.test/graphql",
|
|
{ auth: { type: "none" }, headers: {}, operations: [operation] },
|
|
operation,
|
|
{},
|
|
undefined,
|
|
new AbortController().signal,
|
|
{
|
|
fetch: async () => Response.json({ data: { value: "x".repeat(2_000_000) } }),
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 as const }],
|
|
},
|
|
),
|
|
).rejects.toThrow(/validation limit/);
|
|
});
|
|
|
|
it("yields type error from InstalledConnectorProvider on GraphQL errors array", async () => {
|
|
const events = await executeGraphqlProvider(async () =>
|
|
Response.json({ errors: [{ message: "Field 'hello' is required" }], data: null }),
|
|
);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]).toMatchObject({ type: "error" });
|
|
expect(events[0]).not.toMatchObject({ type: "result" });
|
|
expect(String((events[0] as { message?: string }).message)).toMatch(/hello/i);
|
|
});
|
|
|
|
it("yields type error from InstalledConnectorProvider on GraphQL HTTP failure", async () => {
|
|
const events = await executeGraphqlProvider(
|
|
async () => new Response("nope", { status: 502, headers: { "content-type": "text/plain" } }),
|
|
);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]).toMatchObject({ type: "error" });
|
|
expect(events[0]).not.toMatchObject({ type: "result" });
|
|
expect(String((events[0] as { message?: string }).message)).toMatch(/HTTP 502/);
|
|
});
|
|
|
|
it.each([
|
|
{},
|
|
{ data: null },
|
|
{ errors: [] },
|
|
{ errors: "failed", data: {} },
|
|
{ data: [] },
|
|
{ data: "failed" },
|
|
])("rejects malformed GraphQL envelopes at the provider boundary: %j", async (payload) => {
|
|
const events = await executeGraphqlProvider(async () => Response.json(payload));
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]).toMatchObject({ type: "error" });
|
|
});
|
|
|
|
it("preserves null fields in a valid GraphQL result", async () => {
|
|
const events = await executeGraphqlProvider(async () =>
|
|
Response.json({ data: { hello: null } }),
|
|
);
|
|
expect(events).toEqual([{ type: "result", data: { status: 200, data: { hello: null } } }]);
|
|
});
|
|
|
|
it("yields type error from InstalledConnectorProvider on malformed success payload", async () => {
|
|
const events = await executeGraphqlProvider(
|
|
async () =>
|
|
new Response("<html>upstream unavailable</html>", {
|
|
status: 200,
|
|
headers: { "content-type": "text/html" },
|
|
}),
|
|
);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]).toMatchObject({ type: "error" });
|
|
expect(events[0]).not.toMatchObject({ type: "result" });
|
|
expect(String((events[0] as { message?: string }).message)).toMatch(/not valid JSON/);
|
|
});
|
|
});
|
|
|
|
describe("GraphQL optional-off", () => {
|
|
it("keeps core discovery empty when no GraphQL source is installed", async () => {
|
|
const prisma = {
|
|
capabilityInstall: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
findFirst: vi.fn(),
|
|
},
|
|
};
|
|
const provider = new InstalledConnectorProvider(prisma as never, {} as never);
|
|
const tools = await provider.discoverTools({
|
|
spaceId: "space-1",
|
|
userId: "user-1",
|
|
signal: new AbortController().signal,
|
|
} as never);
|
|
expect(tools).toEqual([]);
|
|
expect(prisma.capabilityInstall.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
kind: { in: ["mcp", "api", "graphql"] },
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|