Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions e2e/scenarios/integrations-catalog-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { scenario } from "../src/scenario";
import { Browser, Target } from "../src/services";
import { clickToReveal, visit } from "../src/surfaces/browser";

// The connect dialog's long-tail search goes to the public integrations.sh
// registry from the browser. CI must not depend on the live service, so both
// registry endpoints are fulfilled at the network layer here — including the
// CORS header a real cross-origin browser call needs.
scenario(
"Connect dialog: integrations.sh catalog search resolves into a prefilled add flow",
{},
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const identity = yield* target.newIdentity();

yield* browser.session(identity, async ({ page, step }) => {
await step("Stub the integrations.sh registry endpoints", async () => {
await page.route("https://integrations.sh/api/search*", (route) =>
route.fulfill({
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
json: {
results: [
{
domain: "todoist.com",
name: "todoist.com",
description: "Tasks, projects, and collaboration.",
kinds: ["mcp", "cli"],
url: "https://integrations.sh/todoist.com/",
},
],
},
}),
);
await page.route("https://integrations.sh/api/todoist.com/surface", (route) =>
route.fulfill({
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
json: {
version: 3,
domain: "todoist.com",
surfaces: [
{ type: "mcp", url: "https://ai.todoist.net/mcp", slug: "todoist" },
{ type: "cli", slug: "todoist-cli" },
],
},
}),
);
});

await step("Searching surfaces the catalog row under the presets", async () => {
await visit(page, "/integrations");
const dialog = page.getByRole("dialog", { name: "Connect an integration" });
await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog);
await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist");
// The CLI-only surface is not offered; the connectable kind is.
await dialog
.getByRole("button", { name: /todoist\.com/ })
.getByText("MCP")
.waitFor();
});

await step("Picking the row lands on the MCP add flow, prefilled", async () => {
const dialog = page.getByRole("dialog", { name: "Connect an integration" });
await dialog.getByRole("button", { name: /todoist\.com/ }).click();
await page.waitForURL(/\/integrations\/add\/mcp/);
const url = new URL(page.url());
expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp");
expect(url.searchParams.get("namespace")).toBe("todoist");
});
});
}),
);
3 changes: 2 additions & 1 deletion packages/react/src/api/analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ export interface AnalyticsEvents {
};
integration_add_started: {
plugin_key: string;
via: "detect" | "manual" | "preset" | "command_palette";
via: "detect" | "manual" | "preset" | "command_palette" | "catalog";
preset_id?: string;
catalog_domain?: string;
};
integration_added: { plugin_key: string; integration_slug?: string };
integration_add_cancelled: { plugin_key: string };
Expand Down
122 changes: 122 additions & 0 deletions packages/react/src/lib/integrations-sh-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it } from "@effect/vitest";
import type { IntegrationPlugin } from "@executor-js/sdk/client";

import {
availableCatalogKinds,
filterCatalogEntries,
parseCatalogSearch,
pickConnectTarget,
presetDomains,
} from "./integrations-sh-catalog";

const plugin = (key: string, presets: IntegrationPlugin["presets"]): IntegrationPlugin => ({
key,
label: key,
add: () => null,
presets,
});

describe("parseCatalogSearch", () => {
it("keeps connectable kinds and drops CLI-only entries", () => {
const entries = parseCatalogSearch({
results: [
{ domain: "linear.app", description: "Issues", kinds: ["mcp", "cli"] },
{ domain: "cli-only.dev", description: "A CLI", kinds: ["cli"] },
],
});
expect(entries).toEqual([{ domain: "linear.app", description: "Issues", kinds: ["mcp"] }]);
});

it("returns nothing for a malformed payload", () => {
expect(parseCatalogSearch({ nope: true })).toEqual([]);
expect(parseCatalogSearch(undefined)).toEqual([]);
});
});

describe("pickConnectTarget", () => {
const payload = {
surfaces: [
{ type: "graphql", url: "https://api.linear.app/graphql", slug: "linear-graphql-api" },
{ type: "mcp", url: "https://mcp.linear.app/mcp", slug: "linear" },
{ type: "http", spec: "https://example.com/openapi.json", slug: "example-rest" },
{ type: "cli", slug: "linear-cli" },
],
};

it("resolves the MCP endpoint for the mcp kind", () => {
expect(pickConnectTarget(payload, "mcp")).toEqual({
kind: "mcp",
url: "https://mcp.linear.app/mcp",
slug: "linear",
});
});

it("resolves the spec URL (not the base URL) for the openapi kind", () => {
expect(pickConnectTarget(payload, "openapi")).toEqual({
kind: "openapi",
url: "https://example.com/openapi.json",
slug: "example-rest",
});
});

it("resolves the GraphQL endpoint for the graphql kind", () => {
expect(pickConnectTarget(payload, "graphql")).toEqual({
kind: "graphql",
url: "https://api.linear.app/graphql",
slug: "linear-graphql-api",
});
});

it("skips specless http surfaces for the openapi kind", () => {
const specless = { surfaces: [{ type: "http", url: "https://api.example.com" }] };
expect(pickConnectTarget(specless, "openapi")).toBeUndefined();
});

it("returns undefined for a malformed document", () => {
expect(pickConnectTarget({ surfaces: "nope" }, "mcp")).toBeUndefined();
});
});

describe("preset filtering", () => {
const plugins: IntegrationPlugin[] = [
plugin("mcp", [
{
id: "linear",
name: "Linear",
summary: "Issues",
icon: "https://integrations.sh/logo/linear.app",
},
]),
plugin("openapi", [
{
id: "stripe",
name: "Stripe",
summary: "Payments",
url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
icon: "https://integrations.sh/logo/stripe.com",
},
]),
];

it("derives preset domains from logo-proxy icons and preset URLs", () => {
const domains = presetDomains(plugins);
expect(domains.has("linear.app")).toBe(true);
expect(domains.has("stripe.com")).toBe(true);
});

it("lists only kinds a loaded plugin can add", () => {
expect(availableCatalogKinds(plugins)).toEqual(["mcp", "openapi"]);
});

it("hides preset-covered domains and unaddable kinds", () => {
const entries = filterCatalogEntries(
[
{ domain: "linear.app", description: "Issues", kinds: ["mcp"] },
{ domain: "shopify.dev", description: "Commerce", kinds: ["graphql"] },
{ domain: "notion.com", description: "Notes", kinds: ["mcp", "graphql"] },
],
{ excludeDomains: presetDomains(plugins), availableKinds: availableCatalogKinds(plugins) },
);
expect(entries).toEqual([{ domain: "notion.com", description: "Notes", kinds: ["mcp"] }]);
});
});
Loading
Loading