History

Add GraphQL invalid introspection repro

Rhys Sullivan committed 4 months ago

Commit 281f7281 parent fd4fb021 file · +152 −0Browse files

Unified · Split

e2e/scenarios/graphql-invalid-format-no-tools.test.ts+152−0
@@ -0,0 +1,152 @@
1// Cross-target (browser): GraphQL source discovery can fail silently at
2// connection-create time. The UI journey is the reported shape: add a GraphQL
3// integration, connect an API key, see the connection saved, then land on an
4// empty Tools tab with no visible error explaining that introspection failed.
5//
6// This is intentionally a passing repro of the current buggy behavior. The
7// upstream is a local node:http server that returns HTTP 200 with a GraphQL-ish
8// body in the wrong introspection shape: `{ "data": {} }`.
9import { randomBytes } from "node:crypto";
10import { createServer } from "node:http";
11
12import { expect } from "@effect/vitest";
13import { Effect } from "effect";
14import { composePluginApi } from "@executor-js/api/server";
15import { ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
16
17import { scenario } from "../src/scenario";
18import { Api, Browser, Target } from "../src/services";
19
20const api = composePluginApi([] as const);
21
22const serveInvalidIntrospectionGraphqlApi = () =>
23 Effect.acquireRelease(
24 Effect.callback<{ readonly endpoint: string; readonly close: () => void }>((resume) => {
25 const server = createServer((request, response) => {
26 if (request.method === "POST" && (request.url ?? "").startsWith("/graphql")) {
27 response.writeHead(200, { "content-type": "application/json" });
28 response.end(JSON.stringify({ data: {} }));
29 return;
30 }
31 response.writeHead(404, { "content-type": "application/json" });
32 response.end(JSON.stringify({ error: "not_found" }));
33 });
34 server.listen(0, "127.0.0.1", () => {
35 const address = server.address();
36 const port = typeof address === "object" && address ? address.port : 0;
37 resume(
38 Effect.succeed({
39 endpoint: `http://127.0.0.1:${port}/graphql`,
40 close: () => {
41 server.close();
42 server.closeAllConnections();
43 },
44 }),
45 );
46 });
47 }),
48 (server) => Effect.sync(server.close),
49 );
50
51scenario(
52 "GraphQL · invalid introspection format creates a connected account with zero tools",
53 {},
54 Effect.scoped(
55 Effect.gen(function* () {
56 const target = yield* Target;
57 const browser = yield* Browser;
58 const { client } = yield* Api;
59 const upstream = yield* serveInvalidIntrospectionGraphqlApi();
60 const identity = yield* target.newIdentity();
61 const apiClient = yield* client(api, identity);
62
63 const slug = IntegrationSlug.make(`graphql_invalid_format_${randomBytes(4).toString("hex")}`);
64
65 yield* Effect.ensuring(
66 Effect.gen(function* () {
67 yield* browser.session(identity, async ({ page, step }) => {
68 await step("Open the Add GraphQL integration form", async () => {
69 await page.goto("/integrations/add/graphql", { waitUntil: "networkidle" });
70 await page.getByRole("heading", { name: "Add GraphQL integration" }).waitFor();
71 });
72
73 await step(
74 "Enter the endpoint and declare Linear-style Authorization API key auth",
75 async () => {
76 await page
77 .getByPlaceholder("https://api.example.com/graphql")
78 .fill(upstream.endpoint);
79 await page.getByPlaceholder("e.g. Shopify API").fill("Linear GraphQL");
80 await page.locator("input").nth(2).fill(String(slug));
81 await page.getByRole("button", { name: "Add method" }).click();
82 await page.getByText("Method 1").waitFor();
83 await page.getByLabel("Header name").fill("Authorization");
84 await page.getByLabel("Prefix").fill("");
85 },
86 );
87
88 await step("Add the GraphQL integration without add-time introspection", async () => {
89 await page.getByRole("button", { name: "Add integration" }).click();
90 await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
91 await page.getByText("Connections").first().waitFor();
92 await page.getByText("No connections yet").waitFor();
93 });
94
95 await step("Connect an API key", async () => {
96 await page.getByRole("button", { name: "Add connection" }).first().click();
97 const dialog = page.getByRole("dialog");
98 await dialog.getByRole("tab", { name: "API key (Authorization)" }).waitFor();
99 await dialog.getByRole("textbox", { name: "Authorization" }).fill("lin_test_key");
100 await dialog.getByRole("button", { name: "Continue" }).click();
101 await dialog.getByLabel("Display name").fill("main");
102 await dialog.getByRole("button", { name: "Add connection" }).click();
103 await page.getByText("Connection added").waitFor({ timeout: 30_000 });
104 await page.getByText("main").waitFor();
105 });
106
107 await step("Open Tools and observe the empty catalog with no error", async () => {
108 await page.getByRole("tab", { name: "Tools" }).click();
109 await page.getByPlaceholder("Filter 0 tools…").waitFor({ timeout: 30_000 });
110 await page.getByText("No tools available").first().waitFor();
111
112 const visibleErrorCopy = await page
113 .getByText(
114 /Failed to add connection|Failed to load tools|Introspection failed|invalid shape/i,
115 )
116 .count();
117 expect(
118 visibleErrorCopy,
119 "no visible error explains the invalid introspection body",
120 ).toBe(0);
121 });
122 });
123
124 const connections = yield* apiClient.connections.list({
125 query: { integration: slug },
126 });
127 expect(connections.length, "the connection was created").toBe(1);
128
129 const tools = yield* apiClient.tools.list({ query: { integration: slug } });
130 expect(tools, "the connected GraphQL integration produced no tools").toEqual([]);
131 }),
132 Effect.gen(function* () {
133 const connections = yield* apiClient.connections
134 .list({ query: { integration: slug } })
135 .pipe(Effect.catch(() => Effect.succeed([])));
136 for (const connection of connections) {
137 yield* apiClient.connections
138 .remove({
139 params: {
140 owner: connection.owner,
141 integration: slug,
142 name: ConnectionName.make(String(connection.name)),
143 },
144 })
145 .pipe(Effect.ignore);
146 }
147 yield* apiClient.integrations.remove({ params: { slug } }).pipe(Effect.ignore);
148 }),
149 );
150 }),
151 ),
152);