Skip to content

Commit 29b7a77

Browse files
authored
Connect dialog: search the integrations.sh catalog (#1763)
* Search the integrations.sh catalog from the connect dialog * Add e2e scenario for catalog search connect flow * Catalog section: skeleton loading rows, neutral labels * Slot catalog results directly into the preset list
1 parent 30cfb4d commit 29b7a77

5 files changed

Lines changed: 580 additions & 5 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { expect } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
4+
import { scenario } from "../src/scenario";
5+
import { Browser, Target } from "../src/services";
6+
import { clickToReveal, visit } from "../src/surfaces/browser";
7+
8+
// The connect dialog's long-tail search goes to the public integrations.sh
9+
// registry from the browser. CI must not depend on the live service, so both
10+
// registry endpoints are fulfilled at the network layer here — including the
11+
// CORS header a real cross-origin browser call needs.
12+
scenario(
13+
"Connect dialog: integrations.sh catalog search resolves into a prefilled add flow",
14+
{},
15+
Effect.gen(function* () {
16+
const target = yield* Target;
17+
const browser = yield* Browser;
18+
const identity = yield* target.newIdentity();
19+
20+
yield* browser.session(identity, async ({ page, step }) => {
21+
await step("Stub the integrations.sh registry endpoints", async () => {
22+
await page.route("https://integrations.sh/api/search*", (route) =>
23+
route.fulfill({
24+
contentType: "application/json",
25+
headers: { "access-control-allow-origin": "*" },
26+
json: {
27+
results: [
28+
{
29+
domain: "todoist.com",
30+
name: "todoist.com",
31+
description: "Tasks, projects, and collaboration.",
32+
kinds: ["mcp", "cli"],
33+
url: "https://integrations.sh/todoist.com/",
34+
},
35+
],
36+
},
37+
}),
38+
);
39+
await page.route("https://integrations.sh/api/todoist.com/surface", (route) =>
40+
route.fulfill({
41+
contentType: "application/json",
42+
headers: { "access-control-allow-origin": "*" },
43+
json: {
44+
version: 3,
45+
domain: "todoist.com",
46+
surfaces: [
47+
{ type: "mcp", url: "https://ai.todoist.net/mcp", slug: "todoist" },
48+
{ type: "cli", slug: "todoist-cli" },
49+
],
50+
},
51+
}),
52+
);
53+
});
54+
55+
await step("Searching surfaces the catalog row under the presets", async () => {
56+
await visit(page, "/integrations");
57+
const dialog = page.getByRole("dialog", { name: "Connect an integration" });
58+
await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog);
59+
await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist");
60+
// The CLI-only surface is not offered; the connectable kind is.
61+
await dialog
62+
.getByRole("button", { name: /todoist\.com/ })
63+
.getByText("MCP")
64+
.waitFor();
65+
});
66+
67+
await step("Picking the row lands on the MCP add flow, prefilled", async () => {
68+
const dialog = page.getByRole("dialog", { name: "Connect an integration" });
69+
await dialog.getByRole("button", { name: /todoist\.com/ }).click();
70+
await page.waitForURL(/\/integrations\/add\/mcp/);
71+
const url = new URL(page.url());
72+
expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp");
73+
expect(url.searchParams.get("namespace")).toBe("todoist");
74+
});
75+
});
76+
}),
77+
);

‎packages/react/src/api/analytics.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ export interface AnalyticsEvents {
3939
};
4040
integration_add_started: {
4141
plugin_key: string;
42-
via: "detect" | "manual" | "preset" | "command_palette";
42+
via: "detect" | "manual" | "preset" | "command_palette" | "catalog";
4343
preset_id?: string;
44+
catalog_domain?: string;
4445
};
4546
integration_added: { plugin_key: string; integration_slug?: string };
4647
integration_add_cancelled: { plugin_key: string };
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import type { IntegrationPlugin } from "@executor-js/sdk/client";
3+
4+
import {
5+
availableCatalogKinds,
6+
filterCatalogEntries,
7+
parseCatalogSearch,
8+
pickConnectTarget,
9+
presetDomains,
10+
} from "./integrations-sh-catalog";
11+
12+
const plugin = (key: string, presets: IntegrationPlugin["presets"]): IntegrationPlugin => ({
13+
key,
14+
label: key,
15+
add: () => null,
16+
presets,
17+
});
18+
19+
describe("parseCatalogSearch", () => {
20+
it("keeps connectable kinds and drops CLI-only entries", () => {
21+
const entries = parseCatalogSearch({
22+
results: [
23+
{ domain: "linear.app", description: "Issues", kinds: ["mcp", "cli"] },
24+
{ domain: "cli-only.dev", description: "A CLI", kinds: ["cli"] },
25+
],
26+
});
27+
expect(entries).toEqual([{ domain: "linear.app", description: "Issues", kinds: ["mcp"] }]);
28+
});
29+
30+
it("returns nothing for a malformed payload", () => {
31+
expect(parseCatalogSearch({ nope: true })).toEqual([]);
32+
expect(parseCatalogSearch(undefined)).toEqual([]);
33+
});
34+
});
35+
36+
describe("pickConnectTarget", () => {
37+
const payload = {
38+
surfaces: [
39+
{ type: "graphql", url: "https://api.linear.app/graphql", slug: "linear-graphql-api" },
40+
{ type: "mcp", url: "https://mcp.linear.app/mcp", slug: "linear" },
41+
{ type: "http", spec: "https://example.com/openapi.json", slug: "example-rest" },
42+
{ type: "cli", slug: "linear-cli" },
43+
],
44+
};
45+
46+
it("resolves the MCP endpoint for the mcp kind", () => {
47+
expect(pickConnectTarget(payload, "mcp")).toEqual({
48+
kind: "mcp",
49+
url: "https://mcp.linear.app/mcp",
50+
slug: "linear",
51+
});
52+
});
53+
54+
it("resolves the spec URL (not the base URL) for the openapi kind", () => {
55+
expect(pickConnectTarget(payload, "openapi")).toEqual({
56+
kind: "openapi",
57+
url: "https://example.com/openapi.json",
58+
slug: "example-rest",
59+
});
60+
});
61+
62+
it("resolves the GraphQL endpoint for the graphql kind", () => {
63+
expect(pickConnectTarget(payload, "graphql")).toEqual({
64+
kind: "graphql",
65+
url: "https://api.linear.app/graphql",
66+
slug: "linear-graphql-api",
67+
});
68+
});
69+
70+
it("skips specless http surfaces for the openapi kind", () => {
71+
const specless = { surfaces: [{ type: "http", url: "https://api.example.com" }] };
72+
expect(pickConnectTarget(specless, "openapi")).toBeUndefined();
73+
});
74+
75+
it("returns undefined for a malformed document", () => {
76+
expect(pickConnectTarget({ surfaces: "nope" }, "mcp")).toBeUndefined();
77+
});
78+
});
79+
80+
describe("preset filtering", () => {
81+
const plugins: IntegrationPlugin[] = [
82+
plugin("mcp", [
83+
{
84+
id: "linear",
85+
name: "Linear",
86+
summary: "Issues",
87+
icon: "https://integrations.sh/logo/linear.app",
88+
},
89+
]),
90+
plugin("openapi", [
91+
{
92+
id: "stripe",
93+
name: "Stripe",
94+
summary: "Payments",
95+
url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
96+
icon: "https://integrations.sh/logo/stripe.com",
97+
},
98+
]),
99+
];
100+
101+
it("derives preset domains from logo-proxy icons and preset URLs", () => {
102+
const domains = presetDomains(plugins);
103+
expect(domains.has("linear.app")).toBe(true);
104+
expect(domains.has("stripe.com")).toBe(true);
105+
});
106+
107+
it("lists only kinds a loaded plugin can add", () => {
108+
expect(availableCatalogKinds(plugins)).toEqual(["mcp", "openapi"]);
109+
});
110+
111+
it("hides preset-covered domains and unaddable kinds", () => {
112+
const entries = filterCatalogEntries(
113+
[
114+
{ domain: "linear.app", description: "Issues", kinds: ["mcp"] },
115+
{ domain: "shopify.dev", description: "Commerce", kinds: ["graphql"] },
116+
{ domain: "notion.com", description: "Notes", kinds: ["mcp", "graphql"] },
117+
],
118+
{ excludeDomains: presetDomains(plugins), availableKinds: availableCatalogKinds(plugins) },
119+
);
120+
expect(entries).toEqual([{ domain: "notion.com", description: "Notes", kinds: ["mcp"] }]);
121+
});
122+
});

0 commit comments

Comments
 (0)