Files
377bc87df1 feat(connectors): show and select personal AI account defaults (#19845)
Show a crown on each saved personal AI provider default. Add a provider-specific menu action to other eligible accounts. Share saved-state queries, block duplicate writes, and keep the saved crown until a write succeeds.

Keep account details available for accessible grants. Show session lookup errors with Retry. Resolve list permissions once per request and index account lookups. Add UI and API regression coverage and account documentation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
2026-11-07 10:26:08 -06:00

2031 lines
116 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { focusManager, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Browse } from "./Browse";
import { ApiError } from "@/api/client";
import { aiConnectionRouterAppDefinition, CONNECTABLE_APP_DEFINITIONS, getAppStoreDefinition } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { AiManagedConnectionSummary } from "@paperclipai/shared";
import type { AggregatorAppCatalogEntry } from "@paperclipai/shared/aggregator-app-catalog";
const aggregatorCatalogMock = vi.hoisted(() => [] as AggregatorAppCatalogEntry[]);
// Counts identity normalizations made by the page (not by the catalog module itself), as a
// deterministic measure of how much catalog work a render does. Past `limit` it throws, so
// a runaway render fails at once instead of blocking the test worker for minutes.
const aggregatorIdentityCalls = vi.hoisted(() => ({ count: 0, limit: Infinity }));
const openNewIssueMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn());
const setupComposioAppMock = vi.hoisted(() => vi.fn());
const listComposioAppsMock = vi.hoisted(() => vi.fn());
const syncComposioAppsMock = vi.hoisted(() => vi.fn());
const refreshComposioAppsMock = vi.hoisted(() => vi.fn());
const manageComposioAppAccountMock = vi.hoisted(() => vi.fn());
function genericResponse(value: { apps?: any[]; sync?: Record<string, unknown> }) {
return { provider: "composio", discovery: { availability: "available", message: null },
sync: { status: "ready", ...value.sync }, apps: (value.apps ?? []).map(snapshot => ({ ...snapshot, provider: snapshot.provider ?? "composio",
appSlug: snapshot.appSlug ?? aggregatorCatalogMock.find(app => app.aliases.includes(snapshot.toolkit) || app.routes.some(route => route.toolkit === snapshot.toolkit))?.slug ?? snapshot.toolkit,
appName: snapshot.appName ?? snapshot.toolkit })) };
}
vi.mock("@/api/agents", () => ({ agentsApi: { list: (companyId: string) => listAgentsMock(companyId) } }));
vi.mock("@paperclipai/shared/aggregator-app-catalog", async (importOriginal) => {
const actual = await importOriginal<typeof import("@paperclipai/shared/aggregator-app-catalog")>();
return {
...actual,
AGGREGATOR_APP_CATALOG: aggregatorCatalogMock,
aggregatorAppIdentity: (value: string) => {
aggregatorIdentityCalls.count += 1;
if (aggregatorIdentityCalls.count > aggregatorIdentityCalls.limit) throw new Error("Connector catalog work exceeded its budget");
return actual.aggregatorAppIdentity(value);
},
findComposioCatalogApp: (toolkit: string) => aggregatorCatalogMock.find(app => app.aliases.includes(toolkit) || app.routes.some(route => route.provider === "composio" && route.toolkit === toolkit)),
};
});
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: openNewIssueMock }) }));
const accountIdentity = vi.hoisted(() => ({ userId: "board-user" as string | null, settled: true, failed: false }));
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => accountIdentity }));
const poolListMock = vi.hoisted(() => vi.fn());
const poolRemoveMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/ai-connection-pools", () => ({ aiConnectionPoolsApi: { list: poolListMock, remove: poolRemoveMock } }));
const assistantConnectionsMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/publicMcp", () => ({ publicMcpApi: { connections: assistantConnectionsMock } }));
const listGalleryMock = vi.hoisted(() => vi.fn());
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listAiConnectionsMock = vi.hoisted(() => vi.fn());
const setAiDefaultMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: { list: listAiConnectionsMock, setDefault: setAiDefaultMock } }));
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
const archiveConnectionMock = vi.hoisted(() => vi.fn());
const pushToastMock = vi.hoisted(() => vi.fn());
const navigateMock = vi.hoisted(() => vi.fn());
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
const experimentalMock = vi.hoisted(() => vi.fn());
const chatSetupMock = vi.hoisted(() => vi.fn());
const emailControlMock = vi.hoisted(() => vi.fn());
const chatListMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: experimentalMock } }));
vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: { list: chatListMock, setup: chatSetupMock } }));
vi.mock("@/api/email", () => ({ emailApi: { control: emailControlMock } }));
vi.mock("@/api/tools", () => ({
toolsApi: {
listAggregatorApps: async (...args: unknown[]) => genericResponse(await listComposioAppsMock(...args)),
syncAggregatorApps: async (...args: unknown[]) => genericResponse(await syncComposioAppsMock(...args)),
refreshAggregatorApps: async (...args: unknown[]) => genericResponse(await refreshComposioAppsMock(...args)),
refreshCatalog: vi.fn().mockResolvedValue({}),
configureArcadeDiscovery: vi.fn(),
syncComposioApps: (...args: unknown[]) => syncComposioAppsMock(...args),
listComposioApps: (...args: unknown[]) => listComposioAppsMock(...args),
refreshComposioApps: (...args: unknown[]) => refreshComposioAppsMock(...args),
manageComposioAppAccount: (...args: unknown[]) => manageComposioAppAccountMock(...args),
setupComposioApp: (...args: unknown[]) => setupComposioAppMock(...args),
listGallery: (companyId: string) => listGalleryMock(companyId),
listApplications: (companyId: string) => listApplicationsMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
archiveConnection: (
connectionId: string,
) => archiveConnectionMock(connectionId),
},
}));
vi.mock("@/api/access", () => ({
accessApi: {
listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId),
},
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
useNavigate: () => navigateMock,
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
useOptionalToastActions: () => ({ pushToast: pushToastMock }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
function galleryEntry(overrides: Record<string, unknown>) {
return {
key: "github",
name: "GitHub",
logoUrl: "https://example.com/github.png",
tagline: "Let agents open pull requests and issues.",
authKind: "oauth",
transportTemplate: {
transport: "mcp_remote",
url: "https://api.github.com/mcp",
},
credentialFields: [],
recommendedDefaults: {},
urlPatterns: [],
...overrides,
};
}
function application(overrides: Record<string, unknown> = {}) {
return {
id: "app-notion",
name: "Notion",
description: "Read and update workspace content.",
status: "active",
applicationKey: "app-gallery:notion:one",
metadata: { sourceTemplateKey: "notion" },
...overrides,
};
}
function connection(overrides: Record<string, unknown> = {}) {
return {
id: "conn-notion",
applicationId: "app-notion",
transport: "mcp_remote",
name: "devinfoley@gmail.com",
status: "active",
enabled: true,
authKind: "oauth",
healthStatus: "ok",
healthMessage: null,
lastError: null,
createdByUserId: "user-1",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
...overrides,
};
}
describe("Connectors landing page", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
accountIdentity.userId = "board-user"; accountIdentity.settled = true;
assistantConnectionsMock.mockReset().mockResolvedValue([]);
syncComposioAppsMock.mockReset().mockImplementation((...args) => listComposioAppsMock(...args));
listComposioAppsMock.mockReset().mockResolvedValue({ apps: [] });
refreshComposioAppsMock.mockReset().mockResolvedValue({ apps: [] });
manageComposioAppAccountMock.mockReset();
listAgentsMock.mockResolvedValue([{ id: "default-agent", name: "Default agent", role: "ceo", reportsTo: null, status: "active", createdAt: new Date(0) }]);
aggregatorCatalogMock.splice(0);
experimentalMock.mockResolvedValue({ enableChatConnectors: true, enableGitHubReviewBots: true });
chatListMock.mockResolvedValue([]);
chatSetupMock.mockReset().mockResolvedValue({ status: "archived" });
emailControlMock.mockReset().mockResolvedValue({ status: "archived" });
listGalleryMock.mockResolvedValue({
apps: [
galleryEntry({
key: "notion",
name: "Notion",
tagline: "Read and update workspace content.",
}),
galleryEntry({
key: "jira",
name: "Jira",
tagline: "Track projects and issues.",
}),
galleryEntry({
key: "gmail",
name: "Gmail",
tagline: "Search and draft email.",
availability: {
available: false,
reason: "Gmail is not available on this Paperclip instance yet.",
},
}),
],
});
listApplicationsMock.mockResolvedValue({ applications: [] });
listConnectionsMock.mockResolvedValue({ connections: [] });
listAiConnectionsMock.mockReset().mockResolvedValue({ currentUserId: "board-user", canManageConnections: true, connections: [] });
setAiDefaultMock.mockReset().mockResolvedValue({ ok: true });
listUserDirectoryMock.mockResolvedValue({ users: [] });
archiveConnectionMock.mockResolvedValue(connection({ status: "archived" }));
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
act(() => root?.unmount());
container.remove();
document.body.innerHTML = "";
window.history.replaceState({}, "", "/");
vi.clearAllMocks();
});
async function renderBrowse(allCatalog = true, options: { cloud?: boolean } = {}) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (options.cloud) client.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } });
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={client}>
<TooltipProvider><Browse /></TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
if (allCatalog) {
await clickButton("All", container);
}
return client;
}
it("shows provider catalog rows and splits legacy gateway accounts by API format", async () => {
const slugs = ["notion", "openrouter", "bedrock", "google", "responses-api", "messages-api", "chat-completions-api", "local"];
listGalleryMock.mockResolvedValue({ apps: slugs.map(slug => getAppStoreDefinition(slug)) });
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Model gateway", applicationKey: "app-gallery:gateway", metadata: { sourceTemplateKey: "gateway" } })] });
listConnectionsMock.mockResolvedValue({ connections: ["responses", "messages"].map(protocol => connection({
id: protocol, name: `${protocol} account`, applicationId: "gateway-app", connectionPurpose: "ai",
config: { sourceTemplateKey: "gateway", ai: { provider: protocol === "messages" ? "anthropic" : "openai", method: "api_key", routing: { kind: "gateway", protocol, auth: "bearer", baseUrl: "https://models.example.com/v1", models: [] } } },
})) });
await renderBrowse();
expect(container.textContent).not.toContain("Connect a model provider");
expect(container.textContent).not.toContain("Model gateway");
for (const slug of slugs) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull();
const responses = container.querySelector('[data-app-slug="responses-api"]')!;
const messages = container.querySelector('[data-app-slug="messages-api"]')!;
expect(responses.textContent).toContain("responses account");
expect(responses.textContent).not.toContain("messages account");
expect(messages.textContent).toContain("messages account");
const bedrock = container.querySelector('[data-app-slug="bedrock"]')!;
const connect = Array.from(bedrock.querySelectorAll('button')).find(button => button.textContent?.includes("Connect"))!;
await act(() => connect.click());
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=bedrock");
});
it("offers assistant setup from Connections without choosing an agent", async () => {
await renderBrowse();
const button = container.querySelector<HTMLButtonElement>('[aria-label="Set up Assistant Connection (MCP)"]');
expect(button).not.toBeNull();
await act(() => button!.click());
expect(navigateMock).toHaveBeenCalledWith("/apps/assistant-connection");
expect(chatSetupMock).not.toHaveBeenCalled();
});
it("keeps assistant setup in the Paperclip catalog when filtering connection sources", async () => {
await renderBrowse(false);
const assistantButton = () => container.querySelector('[aria-label="Set up Assistant Connection (MCP)"]');
expect(assistantButton()).not.toBeNull();
await clickButton("Composio", container);
expect(assistantButton()).toBeNull();
await clickButton("Arcade", container);
expect(assistantButton()).toBeNull();
await clickButton("All", container);
await search("assistant");
expect(assistantButton()).not.toBeNull();
expect(container.textContent).not.toContain("No connectors match");
});
const assistantGrant = { id: "grant", companyId: "company-1", companyName: "Paperclip", clientName: "Claude", scopes: ["paperclip:read"], createdAt: "2026-10-06T00:00:00Z", revokedAt: null };
it("shows active assistant grants in Installed and removes them after revocation", async () => {
assistantConnectionsMock.mockResolvedValue([assistantGrant]);
const client = await renderBrowse(false);
await clickButton("Installed", container);
const manage = container.querySelector<HTMLButtonElement>('[aria-label="Manage Assistant Connection (MCP)"]');
expect(manage).not.toBeNull();
expect(container.querySelector('[data-app-slug="assistant-connection"]')?.textContent).toContain("Claude");
await act(() => manage!.click());
expect(navigateMock).toHaveBeenCalledWith("/apps/assistant-connection");
assistantConnectionsMock.mockResolvedValue([{ ...assistantGrant, revokedAt: "2026-10-06T01:00:00Z" }]);
await act(async () => { await client.invalidateQueries({ queryKey: ["mcp-connections"] }); });
await flushReact();
expect(container.querySelector('[data-app-slug="assistant-connection"]')).toBeNull();
});
it.each([
["empty", []],
["revoked", [{ ...assistantGrant, revokedAt: "2026-10-06T01:00:00Z" }]],
["another organization", [{ ...assistantGrant, companyId: "other-company" }]],
])("does not show %s assistant grants as installed", async (_label, grants) => {
assistantConnectionsMock.mockResolvedValue(grants);
await renderBrowse(false);
await clickButton("Installed", container);
expect(container.querySelector('[data-app-slug="assistant-connection"]')).toBeNull();
});
it("keeps pending assistant status visible in Installed until it can determine access", async () => {
let resolve!: (rows: typeof assistantGrant[]) => void;
assistantConnectionsMock.mockReturnValue(new Promise<typeof assistantGrant[]>(done => { resolve = done; }));
await renderBrowse(false);
await clickButton("Installed", container);
expect(container.textContent).toContain("Checking your connection status");
expect(container.textContent).not.toContain("No connectors match");
await act(() => resolve([assistantGrant]));
await flushReact();
expect(container.querySelector('[aria-label="Manage Assistant Connection (MCP)"]')).not.toBeNull();
});
it("shows a retryable assistant status failure in Installed instead of an empty result", async () => {
assistantConnectionsMock.mockRejectedValue(new Error("offline"));
await renderBrowse(false);
await clickButton("Installed", container);
expect(container.textContent).toContain("Couldn't load your connection status");
expect(container.textContent).not.toContain("No connectors match");
assistantConnectionsMock.mockResolvedValue([assistantGrant]);
await clickButton("Retry", container);
await flushReact();
expect(container.querySelector('[aria-label="Manage Assistant Connection (MCP)"]')).not.toBeNull();
});
function indexedApp(name: string, providers: ("composio" | "arcade" | "executor")[] = ["composio"]): AggregatorAppCatalogEntry {
const slug = name.toLowerCase().replaceAll(" ", "-");
return { name, slug, aliases: [slug], routes: providers.map((provider) => ({
provider, toolkit: slug, logoUrl: `https://logos.example.com/${slug}.svg`, docsUrl: `https://docs.${provider}.dev/${slug}`,
})) };
}
async function search(value: string) {
const input = container.querySelector<HTMLInputElement>('input[aria-label="Search connectors"]')!;
await act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
}
function composioFixture() {
aggregatorCatalogMock.push(indexedApp("Circleback"));
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("composio")] });
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } })] });
const snapshot = { connectionId: "conn-notion", toolkit: "circleback", status: "connected", checkedAt: new Date().toISOString(), accounts: [{ id: "ca-work", alias: "Meeting notes", status: "ACTIVE", isDefault: true }] };
listComposioAppsMock.mockResolvedValue({ apps: [snapshot] });
refreshComposioAppsMock.mockResolvedValue({ apps: [snapshot] });
return snapshot;
}
async function clickButton(text: string, scope: ParentNode = document) {
const button = Array.from(scope.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === text)!;
expect(button).toBeTruthy();
await act(() => button.click());
await flushReact();
}
async function accountMenu(action: string, name = "Meeting notes") {
const trigger = document.querySelector<HTMLButtonElement>(`button[aria-label="Manage ${name}"]`)!;
await act(() => { trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
const item = Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(item => item.textContent?.trim() === action)!;
await act(() => item.click());
await flushReact();
}
async function connectionMenu(name = "Meeting notes") {
const trigger = container.querySelector<HTMLButtonElement>(`[data-app-slug="circleback"] button[aria-label="Manage ${name} connection"]`)!;
expect(trigger).toBeTruthy();
await act(() => { trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
return Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]'));
}
async function openComposioAccountMenu(name = "Composio account") {
const trigger = container.querySelector<HTMLButtonElement>(`[data-app-slug="composio"] button[aria-label="Manage ${name} connection"]`)!;
expect(trigger).toBeTruthy();
await act(() => { trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
return document.querySelector<HTMLElement>('[role="menu"]')!;
}
it("defaults to Paperclip with five chips, expands search to All, and retains explicit source scope", async () => {
aggregatorCatalogMock.push(indexedApp("Remote App", ["composio"]));
await renderBrowse(false);
expect(container.querySelector('[aria-label="App filters"]')?.textContent).toBe("PaperclipComposioArcadeInstalledAll");
expect(container.querySelector('[data-app-slug="remote-app"]')).toBeNull();
await search("Remote App");
expect(container.querySelector('[aria-checked="true"]')?.textContent).toBe("All");
expect(container.querySelector('[data-app-slug="remote-app"]')).toBeTruthy();
await search("");
expect(container.querySelector('[aria-checked="true"]')?.textContent).toBe("Paperclip");
await clickButton("Arcade", container);
await search("Remote App");
expect(container.querySelector('[aria-checked="true"]')?.textContent).toBe("Arcade");
expect(container.textContent).toContain("No connectors match");
await clickButton("Clear search", container);
expect(container.querySelector('[aria-checked="true"]')?.textContent).toBe("Arcade");
});
it.each(["composio", "arcade"] as const)("scopes %s to its catalog and accounts without native catalog overlaps", async (provider) => {
const otherProvider = provider === "composio" ? "arcade" : "composio";
const providerName = provider === "composio" ? "Composio" : "Arcade";
aggregatorCatalogMock.push(indexedApp("Notion", [provider]), indexedApp("Remote App", [provider]), indexedApp("Other App", [otherProvider]));
listGalleryMock.mockResolvedValue({ apps: ["notion", provider, otherProvider].map(getAppStoreDefinition) });
listApplicationsMock.mockResolvedValue({ applications: [application(), application({ id: "gateway-app", name: providerName, metadata: { sourceTemplateKey: provider } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ name: "Native Notion" }), connection({ id: "gateway", applicationId: "gateway-app", name: `${providerName} account`, config: { sourceTemplateKey: provider } })] });
listComposioAppsMock.mockResolvedValue({ apps: [{ provider, appSlug: "notion", appName: "Notion", connectionId: "gateway", toolkit: "notion", status: "not_connected", accounts: [] }] });
await renderBrowse(false);
expect(container.querySelector('[data-app-slug="notion"]')).toBeTruthy();
await clickButton(providerName, container);
expect(container.querySelector(`[data-app-slug="${provider}"]`)).toBeTruthy();
expect(container.querySelector('[data-app-slug="remote-app"]')).toBeTruthy();
expect(container.querySelector('[data-app-slug="notion"]')).toBeNull();
expect(container.querySelector(`[data-app-slug="${otherProvider}"]`)).toBeNull();
expect(container.querySelector('[data-app-slug="other-app"]')).toBeNull();
await search("Notion");
expect(container.querySelector('[aria-checked="true"]')?.textContent).toBe(providerName);
expect(container.textContent).toContain("No connectors match");
await clickButton("All", container);
expect(container.querySelector('[data-app-slug="notion"]')?.textContent).toContain("Native Notion");
});
it("does not reuse another viewing user's managed-account cache", async () => {
composioFixture();
const client = await renderBrowse(false);
expect(container.textContent).toContain("Meeting notes");
listComposioAppsMock.mockResolvedValue({ apps: [] });
accountIdentity.userId = "another-user";
await act(async () => root.render(<QueryClientProvider client={client}><TooltipProvider><Browse /></TooltipProvider></QueryClientProvider>));
await flushReact();
expect(container.textContent).not.toContain("Meeting notes");
expect(client.getQueryData(queryKeys.tools.aggregatorApps("conn-notion", "board-user"))).toBeTruthy();
expect(client.getQueryData<{ apps: unknown[] }>(queryKeys.tools.aggregatorApps("conn-notion", "another-user"))?.apps).toEqual([]);
});
it("groups native and matching-label accounts across providers and gateways without dropping any lines", async () => {
aggregatorCatalogMock.push(indexedApp("Notion", ["composio", "arcade"]));
listGalleryMock.mockResolvedValue({ apps: ["notion", "composio", "arcade", "executor"].map(getAppStoreDefinition) });
const gateways = ["composio", "arcade", "executor", "arcade"];
listApplicationsMock.mockResolvedValue({ applications: [application(), ...gateways.map((provider, index) => application({ id: `app-${index}`, name: provider, metadata: { sourceTemplateKey: provider } }))] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ name: "Native" }), ...gateways.map((provider, index) => connection({ id: `gateway-${index}`, applicationId: `app-${index}`, name: `Gateway ${index}`, config: { sourceTemplateKey: provider } }))] });
listComposioAppsMock.mockImplementation((id: string) => ({ apps: [{ provider: gateways[Number(id.split("-")[1])], appSlug: "notion", appName: "Notion", connectionId: id, toolkit: "notion", checkedAt: new Date().toISOString(), status: "connected", accounts: [{ id: "same-id", alias: "Work", status: "ACTIVE", isDefault: false }] }] }));
await renderBrowse(false);
expect(container.querySelectorAll('[data-app-slug="notion"]')).toHaveLength(1);
const row = container.querySelector('[data-app-slug="notion"]')!;
await clickButton("5 connections", row);
expect(row.querySelectorAll('button[aria-label="Manage Work connection"]')).toHaveLength(4);
expect(row.textContent).toContain("Native");
for (const provider of ["Composio", "Arcade", "Executor"]) expect(row.textContent).toContain(`Managed by ${provider}`);
await clickButton("Arcade", container);
expect(container.querySelectorAll('[data-app-slug="notion"] button[aria-label="Manage Work connection"]')).toHaveLength(4);
expect(container.querySelector('[data-app-slug="notion"] button[aria-label="Connect Notion"]')).toBeNull();
});
it("offers only provider management in imported account menus", async () => {
composioFixture();
await renderBrowse();
const items = await connectionMenu();
expect(items.map(item => item.textContent?.trim())).toEqual(["Open in Composio"]);
expect(items[0].getAttribute("href")).toBe("https://dashboard.composio.dev/~/org/connect/apps");
expect(items[0].getAttribute("target")).toBe("_blank");
expect(navigateMock).not.toHaveBeenCalled();
expect(manageComposioAppAccountMock).not.toHaveBeenCalled();
expect(archiveConnectionMock).not.toHaveBeenCalled();
});
it("does not expose local deletion or renaming for imported accounts", async () => {
composioFixture();
await renderBrowse();
const trigger = container.querySelector<HTMLButtonElement>('button[aria-label="Manage Meeting notes connection"]')!;
await act(() => { trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
expect(document.querySelector('[role="menu"]')?.textContent).toContain("Open in Composio");
expect(document.querySelector('[role="menu"]')?.textContent).not.toContain("Remove connection");
expect(document.querySelector('[role="menu"]')?.textContent).not.toContain("Rename");
expect(manageComposioAppAccountMock).not.toHaveBeenCalled();
});
it("shows verified upstream accounts as installed on every page, searches their labels, and opens Manage", async () => {
composioFixture();
listUserDirectoryMock.mockResolvedValue({ users: [{ principalId: "user-1", status: "active", user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: null } }] });
aggregatorCatalogMock.push(...Array.from({ length: 60 }, (_, index) => indexedApp(`Indexed App ${index}`)));
await renderBrowse();
const circleback = container.querySelector('[data-app-slug="circleback"]')!;
expect(circleback.getAttribute("data-connected")).toBe("true");
expect(circleback.textContent).toContain("Accounts managed in Composio.");
expect(circleback.textContent).toContain("Managed by Composio ·“Composio account”");
expect(circleback.textContent).not.toContain("Connected by");
expect(circleback.textContent).not.toContain("Dotta");
expect(circleback.querySelector('[title="Connected"]')).toBeTruthy();
expect(circleback.querySelector('button[aria-label="Manage Meeting notes"]')).toBeTruthy();
expect(circleback.querySelector('button[aria-label="Connect Circleback"]')).toBeNull();
await clickButton("Next", container);
expect(container.querySelector('[data-app-slug="circleback"] button[aria-label="Manage Circleback"]')).toBeTruthy();
await search("Meeting notes");
expect(container.querySelectorAll('[data-app-slug="circleback"]')).toHaveLength(1);
await clickButton("Manage", container);
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Meeting notes");
expect(document.querySelector('[role="dialog"]')?.textContent).toContain('“Composio account”');
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Accounts and sign-in are managed in Composio.");
expect(document.querySelectorAll('[role="dialog"] a[href="/apps/conn-notion/permissions"]')).toHaveLength(1);
expect(openNewIssueMock).not.toHaveBeenCalled();
});
it("uses the waiting status for an aggregator account until sign-in is verified", async () => {
const snapshot = composioFixture();
const pending = { ...snapshot, status: "not_connected", accounts: [{ ...snapshot.accounts[0], status: "INITIATED" }] };
listComposioAppsMock.mockResolvedValue({ apps: [pending] });
refreshComposioAppsMock.mockResolvedValue({ apps: [pending] });
await renderBrowse();
const circleback = container.querySelector('[data-app-slug="circleback"]')!;
expect(circleback.textContent).toContain("Waiting for sign-in");
expect(circleback.textContent).toContain("Finish connecting this account.");
expect(circleback.querySelector('[title="Connected"]')).toBeNull();
await act(() => circleback.querySelector<HTMLButtonElement>('button[aria-label="Manage Meeting notes"]')!.click());
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Manage Circleback");
});
it("discovers app authorization on refresh and reflects a disconnection when returning from Composio", async () => {
composioFixture();
const response = await listComposioAppsMock();
listComposioAppsMock.mockResolvedValue({ apps: [] });
syncComposioAppsMock.mockResolvedValue(response);
await renderBrowse();
await vi.waitFor(() => expect(container.querySelector('[data-app-slug="circleback"]')?.getAttribute("data-connected")).toBe("true"));
listComposioAppsMock.mockResolvedValue({ apps: [] });
syncComposioAppsMock.mockResolvedValue({ apps: [] });
await act(() => focusManager.setFocused(false));
await act(() => focusManager.setFocused(true));
await vi.waitFor(() => expect(container.querySelector('button[aria-label="Connect Circleback"]')).toBeTruthy());
focusManager.setFocused(undefined);
expect(setupComposioAppMock).not.toHaveBeenCalled();
});
it("opens provider-owned management and refreshes observed accounts without mutation", async () => {
const snapshot = composioFixture();
await renderBrowse();
await clickButton("Manage", container);
const dialog = document.querySelector('[role="dialog"]')!;
expect(dialog.textContent).toContain("Accounts and sign-in are managed in Composio.");
expect(dialog.querySelector('a[href="https://dashboard.composio.dev/~/org/connect/apps"]')?.getAttribute("target")).toBe("_blank");
expect(dialog.textContent).not.toContain("Rename");
expect(dialog.textContent).not.toContain("Disconnect");
const disconnected = { ...snapshot, status: "not_connected", accounts: [] };
refreshComposioAppsMock.mockResolvedValue({ apps: [disconnected] });
await clickButton("Refresh", dialog);
expect(refreshComposioAppsMock).toHaveBeenCalledWith("conn-notion", ["circleback"]);
expect(dialog.textContent).toContain("No connected Circleback accounts.");
expect(manageComposioAppAccountMock).not.toHaveBeenCalled();
expect(setupComposioAppMock).not.toHaveBeenCalled();
});
it("preserves failed observations with an unverified status and offers retry", async () => {
const snapshot = composioFixture();
listComposioAppsMock.mockResolvedValue({ apps: [{ ...snapshot, errorAt: new Date().toISOString() }], sync: { status: "error", error: "Unavailable" } });
await renderBrowse();
expect(container.querySelector('button[aria-label="Manage Circleback"]')).toBeTruthy();
expect(container.querySelector('[data-app-slug="circleback"] [title="Connected"]')).toBeNull();
expect(container.textContent).toContain("Last known account · Refresh to verify");
expect(container.textContent).toContain("Unavailable");
const menu = await openComposioAccountMenu();
await act(() => Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(item => item.textContent?.trim() === "Refresh Composio")!.click());
await flushReact();
expect(syncComposioAppsMock).toHaveBeenCalledWith("conn-notion", true);
});
it("keeps the last known aggregator accounts without a load-error notice when a refetch fails transiently", async () => {
composioFixture();
const client = await renderBrowse();
const circleback = () => container.querySelector('[data-app-slug="circleback"]');
expect(circleback()?.textContent).toContain("Meeting notes");
expect(circleback()?.querySelector('[title="Connected"]')).toBeTruthy();
listComposioAppsMock.mockRejectedValue(new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }));
await act(async () => { await client.invalidateQueries({ queryKey: ["tools", "aggregator-apps"] }); });
await flushReact();
expect(circleback()?.textContent).toContain("Meeting notes");
expect(circleback()?.querySelector('[title="Connected"]')).toBeTruthy();
expect(container.textContent).not.toContain("Last known account · Refresh to verify");
expect(container.textContent).not.toContain("Couldn’t load Composio accounts");
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
expect(container.textContent).not.toContain("Paperclip is restarting.");
});
it("reports a real aggregator account failure while keeping the last known accounts", async () => {
composioFixture();
const client = await renderBrowse();
expect(container.querySelector('[data-app-slug="circleback"]')?.textContent).toContain("Meeting notes");
listComposioAppsMock.mockRejectedValue(new ApiError("Boom", 500, { error: "Boom" }));
await act(async () => { await client.invalidateQueries({ queryKey: ["tools", "aggregator-apps"] }); });
await flushReact();
expect(container.querySelector('[data-app-slug="circleback"]')?.textContent).toContain("Meeting notes");
expect(container.textContent).toContain("Couldn’t load Composio accounts. Use Refresh in the connection’s menu to try again.");
expect(container.textContent).toContain("Last known account · Refresh to verify");
});
it("refreshes only the selected Composio account while another account is syncing", async () => {
composioFixture();
const work = connection({ id: "conn-work", applicationId: "gateway-app", name: "Work Composio", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } });
const personal = connection({ id: "conn-personal", applicationId: "gateway-app", name: "Personal Composio", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } });
listConnectionsMock.mockResolvedValue({ connections: [work, personal] });
listComposioAppsMock.mockImplementation((id: string) => Promise.resolve({ apps: [], sync: { status: id === "conn-work" ? "syncing" : "ready" } }));
await renderBrowse();
expect(container.textContent).not.toContain("Refresh Composio");
await clickButton("2 connections", container.querySelector('[data-app-slug="composio"]')!);
let menu = await openComposioAccountMenu("Work Composio");
expect(Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(item => item.textContent?.trim() === "Refresh Composio")?.getAttribute("aria-disabled")).toBe("true");
await act(() => { menu.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); });
await flushReact();
menu = await openComposioAccountMenu("Personal Composio");
const refresh = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(item => item.textContent?.trim() === "Refresh Composio")!;
expect(refresh.getAttribute("aria-disabled")).not.toBe("true");
syncComposioAppsMock.mockClear();
await act(() => refresh.click());
await flushReact();
expect(syncComposioAppsMock.mock.calls.filter(([, force]) => force === true)).toEqual([["conn-personal", true]]);
});
it("discovers native-overlap accounts without adding an aggregator connect offer", async () => {
const snapshot = composioFixture();
aggregatorCatalogMock.push(indexedApp("Notion"));
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("composio"), getAppStoreDefinition("notion")] });
listComposioAppsMock.mockResolvedValue({ apps: [{ ...snapshot, toolkit: "notion", accounts: [{ ...snapshot.accounts[0], alias: "Imported workspace" }] }] });
await renderBrowse();
const row = container.querySelector('[data-app-slug="notion"]')!;
expect(row.textContent).toContain("Imported workspace");
expect(row.textContent).toContain("Managed by Composio ·“Composio account”");
expect(row.querySelector('button[aria-label*="third-party"]')).toBeNull();
expect(row.querySelector('button[aria-label="Connect Notion"]')).toBeTruthy();
expect(syncComposioAppsMock).toHaveBeenCalledWith("conn-notion");
expect(refreshComposioAppsMock).not.toHaveBeenCalled();
});
it("suppresses aggregator routes for native apps, including hidden native connectors", async () => {
experimentalMock.mockResolvedValue({ enableChatConnectors: true, enableGitHubReviewBots: true, enableGoogleWorkspaceConnector: true });
aggregatorCatalogMock.push(indexedApp("Notion", ["composio", "arcade"]), indexedApp("Google Sheets"), indexedApp("GitHub API"), indexedApp("Context7"), indexedApp("HubSpot", ["composio", "arcade"]));
await renderBrowse();
expect(container.querySelectorAll('[data-app-slug="notion"]')).toHaveLength(1);
expect(container.querySelector('[data-app-slug="notion"] button[aria-label*="third-party"]')).toBeNull();
expect(container.querySelector('[data-app-slug="google-sheets"]')).toBeNull();
expect(container.querySelector('[data-app-slug="github-api"]')).toBeNull();
expect(container.querySelector('[data-app-slug="context7"]')).toBeNull();
expect(container.querySelectorAll('[data-app-slug="hubspot"]')).toHaveLength(1);
expect(container.querySelectorAll('[data-app-slug="hubspot"] button[aria-label*="third-party"]')).toHaveLength(2);
});
// BUG-06: on Paperclip Cloud, Slack's built-in tool method needs a customer-created OAuth
// app and, without the Chat connectors opt-in, there is no managed Slack path. Connect
// must offer the aggregators' one-click sign-in instead of the "needs its own OAuth app" form.
it("offers aggregator Slack sign-in on Cloud while the built-in Slack connector needs a customer OAuth app", async () => {
experimentalMock.mockResolvedValue({});
aggregatorCatalogMock.push(indexedApp("Slack", ["composio", "arcade"]));
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("slack"), getAppStoreDefinition("notion")] });
await renderBrowse(true, { cloud: true });
const rows = container.querySelectorAll('[data-app-slug="slack"]');
expect(rows).toHaveLength(1);
expect(rows[0]!.textContent).toContain("Connect through Composio or Arcade.");
await clickButton("Connect", rows[0]!);
expect(navigateMock).not.toHaveBeenCalledWith("/apps/connect?source=slack");
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Which service would you like to use?");
});
it.each([
["Chat connectors offer its setup path", { enableChatConnectors: true }, true],
["the instance is self-hosted", {}, false],
])("keeps the built-in Slack connector when %s", async (_label, experimental, cloud) => {
experimentalMock.mockResolvedValue(experimental);
aggregatorCatalogMock.push(indexedApp("Slack", ["composio", "arcade"]));
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("slack")] });
await renderBrowse(true, { cloud });
const rows = container.querySelectorAll('[data-app-slug="slack"]');
expect(rows).toHaveLength(1);
expect(rows[0]!.textContent).not.toContain("Connect through Composio");
});
it("keeps native Asana on Cloud when the gallery omits its unavailable managed method", async () => {
experimentalMock.mockResolvedValue({});
aggregatorCatalogMock.push(indexedApp("Asana", ["composio", "arcade"]));
const asana = getAppStoreDefinition("asana")!;
// The gallery removes a Paperclip Cloud method whose broker profile is not advertised.
const galleryAsana = { ...asana, methods: asana.methods.filter((method) => !method.oauthStrategy) };
listGalleryMock.mockResolvedValue({ apps: [galleryAsana] });
await renderBrowse(true, { cloud: true });
const rows = container.querySelectorAll('[data-app-slug="asana"]');
expect(rows).toHaveLength(1);
expect(rows[0]!.textContent).not.toContain("Connect through Composio");
});
it("keeps a saved built-in Slack connection on its native row on Cloud", async () => {
experimentalMock.mockResolvedValue({});
aggregatorCatalogMock.push(indexedApp("Slack", ["composio", "arcade"]));
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("slack")] });
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "slack-app", name: "Slack", metadata: { sourceTemplateKey: "slack" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "slack-app", name: "Team Slack", transport: "mcp_remote", config: { sourceTemplateKey: "slack" } })] });
await renderBrowse(true, { cloud: true });
const rows = container.querySelectorAll('[data-app-slug="slack"]');
expect(rows).toHaveLength(1);
expect(rows[0]!.textContent).toContain("Team Slack");
});
// The other tests use a handful of catalog entries, which hides how the work grows.
// Load the shipped catalogs (about 1,600 aggregator apps and every native connector) in
// managed-instance mode, where native rows check aggregator routes, and bound the
// identity work. A nested scan of the catalog per row needs tens of millions of calls
// and stalls the tab.
it("builds the full shipped catalog on a managed instance with work proportional to its size, and not again for a search or an unchanged refetch", async () => {
const { AGGREGATOR_APP_CATALOG: shippedCatalog } = await vi.importActual<typeof import("@paperclipai/shared/aggregator-app-catalog")>("@paperclipai/shared/aggregator-app-catalog");
aggregatorCatalogMock.push(...shippedCatalog);
experimentalMock.mockResolvedValue({});
listGalleryMock.mockResolvedValue({ apps: CONNECTABLE_APP_DEFINITIONS });
// A saved Composio gateway with one observed account, so the per-gateway query data
// takes part in the row build.
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } })] });
const snapshot = { connectionId: "conn-notion", toolkit: "fireflies", status: "connected", checkedAt: new Date(0).toISOString(), accounts: [{ id: "ca-work", alias: "Meeting notes", status: "ACTIVE", isDefault: true }] };
// Each call returns a new but equal object, as a real refetch does.
listComposioAppsMock.mockImplementation(async () => ({ apps: [structuredClone(snapshot)] }));
const catalogNames = shippedCatalog.reduce((total, app) => total + 2 + app.aliases.length, 0);
const accountsKey = queryKeys.tools.aggregatorApps("conn-notion", "board-user");
// Today a full load normalizes about five names per catalog name.
aggregatorIdentityCalls.count = 0;
aggregatorIdentityCalls.limit = catalogNames * 20;
try {
const client = await renderBrowse(true, { cloud: true });
expect(container.querySelector('[data-app-slug="fireflies"]')?.textContent).toContain("Meeting notes");
expect(aggregatorIdentityCalls.count).toBeLessThan(catalogNames * 20);
// An unchanged refetch keeps the query data identity, so the rows are not rebuilt.
aggregatorIdentityCalls.count = 0;
await act(async () => { await client.refetchQueries({ queryKey: accountsKey, exact: true }); });
await flushReact();
expect(aggregatorIdentityCalls.count).toBeLessThan(catalogNames);
// Changed accounts still reach the page.
listComposioAppsMock.mockImplementation(async () => ({ apps: [{ ...snapshot, accounts: [...snapshot.accounts, { id: "ca-team", alias: "Team notes", status: "ACTIVE", isDefault: false }] }] }));
await act(async () => { await client.refetchQueries({ queryKey: accountsKey, exact: true }); });
await flushReact();
expect(container.querySelector('[data-app-slug="fireflies"]')?.textContent).toContain("Team notes");
// Typing a search changes no connector data, so the rows are not rebuilt.
aggregatorIdentityCalls.count = 0;
await search("slack");
expect(aggregatorIdentityCalls.count).toBeLessThan(catalogNames);
} finally {
aggregatorIdentityCalls.limit = Infinity;
}
});
it("paginates the catalog while keeping installed connectors above every page and resets on search", async () => {
aggregatorCatalogMock.push(...Array.from({ length: 60 }, (_, index) => indexedApp(`Indexed App ${String(index).padStart(2, "0")}`)));
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({ connections: [connection()] });
await renderBrowse();
expect(container.querySelectorAll('[data-connected="false"][data-app-slug]:not([data-app-slug="custom-mcp"]):not([data-app-slug="assistant-connection"])')).toHaveLength(50);
expect(container.querySelector('[aria-label="Connector list"] > [data-app-slug]:not([data-app-slug="assistant-connection"])')?.getAttribute("data-app-slug")).toBe("notion");
const next = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Next")!;
await act(() => next.click());
expect(container.textContent).toContain("Page 2 of");
expect(container.querySelector('[aria-label="Connector list"] > [data-app-slug]:not([data-app-slug="assistant-connection"])')?.getAttribute("data-app-slug")).toBe("notion");
await search("Indexed App 59");
expect(container.textContent).toContain("Page 1 of 1");
expect(container.querySelector('[data-app-slug="indexed-app-59"]')).not.toBeNull();
await search("no-such-app");
expect(container.textContent).toContain("No connectors match");
expect(container.querySelector('[aria-label="Connector catalog pages"]')).toBeNull();
});
it("offers each provider once and carries the exact selected toolkit to setup", async () => {
aggregatorCatalogMock.push(indexedApp("HubSpot", ["composio", "arcade"]));
await renderBrowse();
await act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Connect HubSpot"]')!.click());
const dialog = document.querySelector('[role="dialog"]')!;
expect(dialog.textContent).toContain("Which service would you like to use?");
await act(() => Array.from(dialog.querySelectorAll("button")).find((button) => button.textContent?.includes("Arcade"))!.click());
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=arcade&targetToolkit=hubspot");
});
it.each(["composio", "arcade"] as const)("skips provider selection and opens setup for a sole %s provider", async (provider) => {
const app = indexedApp("Circleback", [provider]);
app.routes[0].toolkit = "circle_back";
aggregatorCatalogMock.push(app);
await renderBrowse();
await act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Connect Circleback"]')!.click());
expect(navigateMock).toHaveBeenCalledWith(`/apps/connect?source=${provider}&targetToolkit=circle_back`);
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it("opens the saved gateway flow directly for a sole provider and allows cancellation", async () => {
aggregatorCatalogMock.push(indexedApp("Circleback"));
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("composio")] });
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", config: { sourceTemplateKey: "composio" } })] });
await renderBrowse();
await act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Connect Circleback"]')!.click());
const dialog = document.querySelector('[role="dialog"]')!;
expect(dialog.textContent).toContain("Connect Circleback through Composio");
expect(dialog.textContent).not.toContain("Which service would you like to use?");
expect(dialog.textContent).not.toContain("Back");
expect(dialog.textContent).not.toContain("Connection settings");
expect(dialog.textContent).not.toContain("An agent can check Circleback in Composio");
expect(dialog.querySelector('[data-slot="badge"]')).toBeNull();
expect(dialog.querySelector<HTMLSelectElement>('#composio-app-account')?.value).toBe("conn-notion");
expect(dialog.querySelector('#composio-app-agent')).toBeNull();
expect(dialog.textContent).not.toContain("Which agent");
expect(dialog.textContent).not.toContain("Using");
expect(dialog.textContent).not.toContain("Use another account");
expect(dialog.querySelector('a[href="https://dashboard.composio.dev/~/org/connect/apps"]')).toBeNull();
expect(listAgentsMock).not.toHaveBeenCalled();
expect(navigateMock).not.toHaveBeenCalled();
await act(() => Array.from(dialog.querySelectorAll("button")).find((button) => button.textContent === "Cancel")!.click());
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it("configures an app directly through a saved gateway without creating an agent task", async () => {
setupComposioAppMock.mockResolvedValueOnce({ status: "authorization_required", authorizationUrl: "https://connect.composio.dev/link/test" })
.mockRejectedValueOnce(new Error("Finish connecting this app in Composio, then check again")).mockResolvedValueOnce({ status: "connected" });
aggregatorCatalogMock.push(indexedApp("HubSpot", ["composio", "arcade"]));
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("composio")] });
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", config: { sourceTemplateKey: "composio" } })] });
await renderBrowse();
expect(container.querySelector('[data-app-slug="hubspot"]')?.getAttribute("data-connected")).toBe("false");
await act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Connect HubSpot"]')!.click());
await act(() => Array.from(document.querySelector('[role="dialog"]')!.querySelectorAll("button")).find((button) => button.textContent?.includes("Composio"))!.click());
expect(document.querySelector<HTMLSelectElement>('#composio-app-account')?.value).toBe("conn-notion");
expect(navigateMock).not.toHaveBeenCalled();
await act(() => Array.from(document.querySelector('[role="dialog"]')!.querySelectorAll("button")).find((button) => button.textContent === "Continue")!.click());
await vi.waitFor(() => expect(setupComposioAppMock).toHaveBeenCalledWith("conn-notion", "hubspot", { action: "start" }));
await vi.waitFor(() => expect(document.querySelector('a[href="https://connect.composio.dev/link/test"]')).not.toBeNull());
expect(openNewIssueMock).not.toHaveBeenCalled();
await act(() => Array.from(document.querySelector('[role="dialog"]')!.querySelectorAll("button")).find((button) => button.textContent === "I’ve connected it")!.click());
await vi.waitFor(() => expect(setupComposioAppMock).toHaveBeenCalledWith("conn-notion", "hubspot", { action: "complete" }));
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain("Finish connecting"));
expect(document.querySelector('a[href="https://connect.composio.dev/link/test"]')).not.toBeNull();
await act(() => Array.from(document.querySelector('[role="dialog"]')!.querySelectorAll("button")).find((button) => button.textContent === "I’ve connected it")!.click());
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
expect(pushToastMock).toHaveBeenCalledWith({ title: "HubSpot is connected through Composio.", tone: "success" });
expect(openNewIssueMock).not.toHaveBeenCalled();
expect(navigateMock).not.toHaveBeenCalled();
});
it("uses the selected saved Composio account and clears the previous account's sign-in link", async () => {
composioFixture();
listComposioAppsMock.mockResolvedValue({ apps: [] });
refreshComposioAppsMock.mockResolvedValue({ apps: [] });
listConnectionsMock.mockResolvedValue({ connections: [
connection({ applicationId: "gateway-app", name: "Work Composio", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } }),
connection({ id: "conn-personal", applicationId: "gateway-app", name: "Personal Composio", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } }),
connection({ id: "conn-paused", applicationId: "gateway-app", name: "Paused Composio", enabled: false, config: { sourceTemplateKey: "composio" } }),
] });
listAgentsMock.mockRejectedValue(new Error("Agents are unavailable"));
setupComposioAppMock.mockReset().mockResolvedValueOnce({ status: "authorization_required", authorizationUrl: "https://connect.composio.dev/link/personal" }).mockResolvedValueOnce({ status: "connected" });
await renderBrowse();
await act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Connect Circleback"]')!.click());
const select = document.querySelector<HTMLSelectElement>('#composio-app-account')!;
expect(Array.from(select.options).map(option => option.textContent)).toEqual(["Work Composio", "Personal Composio", "Connect a new account…"]);
await act(() => { select.value = "conn-personal"; select.dispatchEvent(new Event("change", { bubbles: true })); });
await flushReact();
await clickButton("Continue", document.querySelector('[role="dialog"]')!);
expect(setupComposioAppMock).toHaveBeenCalledWith("conn-personal", "circleback", { action: "start" });
expect(document.querySelector('a[href="https://connect.composio.dev/link/personal"]')).toBeTruthy();
await act(() => { select.value = "conn-notion"; select.dispatchEvent(new Event("change", { bubbles: true })); });
await flushReact();
expect(document.querySelector('a[href="https://connect.composio.dev/link/personal"]')).toBeNull();
await clickButton("Continue", document.querySelector('[role="dialog"]')!);
expect(setupComposioAppMock).toHaveBeenLastCalledWith("conn-notion", "circleback", { action: "start" });
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(listAgentsMock).not.toHaveBeenCalled();
expect(openNewIssueMock).not.toHaveBeenCalled();
listAgentsMock.mockResolvedValue([]);
});
it("offers a new Composio account in the dropdown and carries the requested app into setup", async () => {
composioFixture();
listComposioAppsMock.mockResolvedValue({ apps: [] });
refreshComposioAppsMock.mockResolvedValue({ apps: [] });
setupComposioAppMock.mockReset();
await renderBrowse();
await act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Connect Circleback"]')!.click());
const select = document.querySelector<HTMLSelectElement>('#composio-app-account')!;
await act(() => { select.value = "__new__"; select.dispatchEvent(new Event("change", { bubbles: true })); });
await flushReact();
expect(navigateMock).not.toHaveBeenCalled();
await clickButton("Connect new account", document.querySelector('[role="dialog"]')!);
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=composio&targetToolkit=circleback&new=1");
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(setupComposioAppMock).not.toHaveBeenCalled();
expect(listAgentsMock).not.toHaveBeenCalled();
});
it("returns from Composio gateway setup directly to its app flow even with multiple providers", async () => {
aggregatorCatalogMock.push(indexedApp("HubSpot", ["composio", "arcade"]));
window.history.replaceState({}, "", "/apps?source=composio&targetToolkit=hubspot");
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("composio")] });
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", config: { sourceTemplateKey: "composio" } })] });
await renderBrowse();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')?.textContent).toContain("through Composio"));
expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain("Which service would you like to use?");
expect(document.querySelector<HTMLSelectElement>('#composio-app-account')?.value).toBe("conn-notion");
expect(document.querySelector('#composio-app-agent')).toBeNull();
expect(openNewIssueMock).not.toHaveBeenCalled();
});
it("shows retirement guidance before paused state for an obsolete Composio account", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "old-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "old-app", enabled: false, healthStatus: "error", transport: "rest_api", config: { sourceTemplateKey: "composio", connectionMethodKey: "api-key" } })] });
await renderBrowse();
expect(container.textContent).toContain("Retired");
expect(container.textContent).toContain("Add a new Composio MCP connection");
expect(container.textContent).not.toContain("Paused");
});
const googleSlugs = [
"google-workspace",
"gmail", "google-drive", "google-docs", "google-sheets", "google-slides",
"google-calendar", "google-chat", "google-people", "google-workspace-search",
];
it("offers one Google Workspace connector while retaining the individual service definitions", async () => {
experimentalMock.mockResolvedValue({ enableGoogleWorkspaceConnector: true });
const definitions = googleSlugs.map((slug) => getAppStoreDefinition(slug)!);
listGalleryMock.mockResolvedValue({ apps: [...definitions, getAppStoreDefinition("notion")] });
const client = await renderBrowse();
for (const definition of definitions) {
expect(definition.methods.length).toBeGreaterThan(0);
expect(getAppStoreDefinition(definition.slug)).toBe(definition);
if (definition.slug === "google-workspace") {
expect(container.querySelectorAll('[data-app-slug="google-workspace"]')).toHaveLength(1);
} else {
expect(container.querySelector(`[data-app-slug="${definition.slug}"]`)).toBeNull();
}
}
expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull();
expect(client.getQueryData(queryKeys.apps.gallery("company-1"))).toEqual({
apps: [...definitions, getAppStoreDefinition("notion")],
});
expect(archiveConnectionMock).not.toHaveBeenCalled();
await clickButton("Connect", container.querySelector('[data-app-slug="google-workspace"]')!);
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=google-workspace");
});
function googleAggregatorApp(name: string, toolkit: string): AggregatorAppCatalogEntry {
return { name, slug: toolkit, aliases: [toolkit], routes: [{ provider: "composio", toolkit,
logoUrl: `https://logos.composio.dev/api/${toolkit}`, docsUrl: `https://docs.composio.dev/toolkits/${toolkit}` }] };
}
it("hides the built-in Google Workspace connector by default and offers aggregator Google apps", async () => {
experimentalMock.mockResolvedValue({});
aggregatorCatalogMock.push(googleAggregatorApp("Gmail", "gmail"), googleAggregatorApp("Google Calendar", "googlecalendar"),
googleAggregatorApp("Google Drive", "googledrive"));
listGalleryMock.mockResolvedValue({ apps: [...googleSlugs.map(getAppStoreDefinition), getAppStoreDefinition("notion")] });
await renderBrowse();
expect(container.querySelector('[data-app-slug="google-workspace"]')).toBeNull();
for (const slug of ["gmail", "googlecalendar", "googledrive"]) {
const row = container.querySelector(`[data-app-slug="${slug}"]`);
expect(row, slug).not.toBeNull();
expect(row?.textContent).toContain("Composio");
}
expect(container.querySelector('[data-app-slug="google-calendar"]')).toBeNull();
expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull();
});
it("shows an existing aggregator Google account once, on the aggregator row, while the built-in connector is off", async () => {
experimentalMock.mockResolvedValue({});
aggregatorCatalogMock.push(googleAggregatorApp("Google Calendar", "googlecalendar"));
listGalleryMock.mockResolvedValue({ apps: [...googleSlugs.map(getAppStoreDefinition), getAppStoreDefinition("composio")] });
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } })] });
const snapshot = { connectionId: "conn-notion", toolkit: "googlecalendar", status: "connected", checkedAt: new Date().toISOString(),
accounts: [{ id: "ca-calendar", alias: "Work calendar", status: "ACTIVE", isDefault: true }] };
listComposioAppsMock.mockResolvedValue({ apps: [snapshot] });
refreshComposioAppsMock.mockResolvedValue({ apps: [snapshot] });
await renderBrowse();
expect(container.querySelector('[data-app-slug="google-calendar"]')).toBeNull();
expect(container.querySelector('[data-app-slug="googlecalendar"]')?.textContent).toContain("Work calendar");
expect(container.textContent?.split("Work calendar").length).toBe(2);
});
it("keeps aggregator Google apps folded into the built-in connector when it is enabled", async () => {
experimentalMock.mockResolvedValue({ enableGoogleWorkspaceConnector: true });
aggregatorCatalogMock.push(googleAggregatorApp("Gmail", "gmail"), googleAggregatorApp("Google Calendar", "googlecalendar"));
listGalleryMock.mockResolvedValue({ apps: googleSlugs.map(getAppStoreDefinition) });
await renderBrowse();
expect(container.querySelector('[data-app-slug="google-workspace"]')).not.toBeNull();
expect(container.querySelector('[data-app-slug="gmail"]')).toBeNull();
expect(container.querySelector('[data-app-slug="googlecalendar"]')).toBeNull();
});
it.each(["active", "draft", "disabled"])(
"shows saved Google %s accounts without changing their access or status",
async (status) => {
const applications = googleSlugs.map((slug) => application({
id: `app-${slug}`, name: `Saved ${slug}`, applicationKey: `app-gallery:${slug}:one`,
metadata: { sourceTemplateKey: slug },
}));
const connections = googleSlugs.map((slug) => connection({
id: `conn-${slug}`, applicationId: `app-${slug}`, name: `Account for ${slug}`,
status, enabled: status !== "disabled", config: { sourceTemplateKey: slug },
}));
listGalleryMock.mockResolvedValue({ apps: googleSlugs.map(getAppStoreDefinition) });
listApplicationsMock.mockResolvedValue({ applications });
listConnectionsMock.mockResolvedValue({ connections });
const client = await renderBrowse();
for (const slug of googleSlugs) {
expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull();
expect(container.textContent).toContain(`Account for ${slug}`);
}
expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections });
expect(client.getQueryData(queryKeys.tools.applications("company-1"))).toEqual({ applications });
expect(archiveConnectionMock).not.toHaveBeenCalled();
},
);
it.each(["config", "transportConfig"])(
"shows a Google account identified by %s even when its gallery entry is absent",
async (sourceField) => {
listGalleryMock.mockResolvedValue({ apps: [] });
listApplicationsMock.mockResolvedValue({ applications: [application({
id: "legacy-google", name: "My documents", applicationKey: null, metadata: null,
}), application()] });
listConnectionsMock.mockResolvedValue({ connections: [connection({
id: "legacy-google-account", applicationId: "legacy-google", name: "Saved Google account",
config: {}, transportConfig: {}, [sourceField]: { sourceTemplateKey: "google-docs" },
}), connection()] });
await renderBrowse();
expect(container.textContent).toContain("Saved Google account");
expect(container.textContent).toContain("Notion");
expect(archiveConnectionMock).not.toHaveBeenCalled();
},
);
it.each(["catalog", "custom"])(
"preserves both Google and non-Google accounts in a mixed-provider %s row",
async (rowKind) => {
const notion = getAppStoreDefinition("notion")!;
listGalleryMock.mockResolvedValue({ apps: rowKind === "catalog" ? [notion] : [] });
listApplicationsMock.mockResolvedValue({ applications: [application(rowKind === "custom"
? { name: "My tools", applicationKey: null, metadata: null }
: {})] });
const connections = [connection({
id: "google-account", name: "Saved Google account",
config: { sourceTemplateKey: "google-docs" },
}), connection({
id: "notion-account", name: "Visible Notion account",
config: { sourceTemplateKey: "notion" },
})];
listConnectionsMock.mockResolvedValue({ connections });
const client = await renderBrowse();
await clickButton("2 connections");
expect(container.textContent).toContain("Visible Notion account");
expect(container.textContent).toContain("Saved Google account");
expect(container.textContent).toContain(rowKind === "catalog" ? "Notion" : "My tools");
expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections });
expect(archiveConnectionMock).not.toHaveBeenCalled();
},
);
it.each(["metadata", "applicationKey"])(
"keeps a non-Google custom connector identified by %s and its saved Google account",
async (sourceField) => {
listGalleryMock.mockResolvedValue({ apps: [] });
const savedApplication = application({
name: "My custom connector",
metadata: sourceField === "metadata" ? { sourceTemplateKey: "custom-provider" } : null,
applicationKey: sourceField === "applicationKey" ? "custom-provider" : null,
});
listApplicationsMock.mockResolvedValue({ applications: [savedApplication] });
const connections = [connection({
name: "Saved Google account", config: { sourceTemplateKey: "google-docs" },
})];
listConnectionsMock.mockResolvedValue({ connections });
const client = await renderBrowse();
expect(container.querySelector('[data-app-slug="custom-provider"]')).not.toBeNull();
expect(container.textContent).toContain("My custom connector");
expect(container.textContent).toContain("Saved Google account");
expect(client.getQueryData(queryKeys.tools.applications("company-1"))).toEqual({ applications: [savedApplication] });
expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections });
expect(archiveConnectionMock).not.toHaveBeenCalled();
},
);
it("uses availability metadata for a non-Notebook connector in cached gallery results", async () => {
const privateResearch = { ...getAppStoreDefinition("notion")!, slug: "private-research", name: "Private Research", experimentalFeatureKey: "enableCases" as const };
listGalleryMock.mockResolvedValue({ apps: [privateResearch, getAppStoreDefinition("context7")] });
const client = await renderBrowse();
expect(container.querySelector('[data-app-slug="private-research"]')).toBeNull();
expect(container.querySelector('[data-app-slug="context7"]')).not.toBeNull();
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableCases: true }); });
await flushReact();
expect(container.querySelector('[data-app-slug="private-research"]')).not.toBeNull();
});
it("hides cached Notebook catalog entries until explicitly enabled", async () => {
listGalleryMock.mockResolvedValue({ apps: ["paperclip-notebook", "notion"].map(getAppStoreDefinition) });
const client = await renderBrowse();
expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).toBeNull();
expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull();
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enablePaperclipNotebook: true }); });
await flushReact();
expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).not.toBeNull();
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enablePaperclipNotebook: false }); });
await flushReact();
expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).toBeNull();
});
it("hides Notebook while settings load", async () => {
experimentalMock.mockImplementation(() => new Promise(() => {}));
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("paperclip-notebook")] });
await renderBrowse();
expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).toBeNull();
});
it("hides Notebook when a settings refresh fails with a cached opt-in", async () => {
experimentalMock.mockResolvedValue({ enablePaperclipNotebook: true });
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("paperclip-notebook")] });
const client = await renderBrowse();
expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).not.toBeNull();
experimentalMock.mockRejectedValue(new Error("Settings unavailable"));
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }); });
await flushReact();
expect(client.getQueryData(queryKeys.instance.experimentalSettings)).toEqual({ enablePaperclipNotebook: true });
expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).toBeNull();
});
it("keeps Notebook removal available while hiding reconnect and new-account controls", async () => {
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("paperclip-notebook")] });
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "notebook-app", name: "Paperclip Notebook",
applicationKey: "app-gallery:paperclip-notebook:one", metadata: { sourceTemplateKey: "paperclip-notebook" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ id: "saved-notebook", applicationId: "notebook-app",
name: "Notebook account", config: { sourceTemplateKey: "paperclip-notebook" }, healthStatus: "error", requiresReauthorization: true })] });
await renderBrowse();
const row = container.querySelector('[data-app-slug="paperclip-notebook"]')!;
expect(row).not.toBeNull();
expect([...row.querySelectorAll("button")].some(button => /^(Connect|Add connection|Reconnect|Finish setup)$/.test(button.textContent?.trim() ?? ""))).toBe(false);
await act(() => { row.querySelector<HTMLButtonElement>('button[aria-label^="Manage "]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
await act(() => { [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find(item => item.textContent?.trim() === "Remove connection")!.click(); });
await flushReact();
await act(() => { [...document.querySelectorAll("button")].find(button => button.textContent?.trim() === "Remove connection")!.click(); });
await flushReact();
expect(archiveConnectionMock).toHaveBeenCalledWith("saved-notebook");
});
it("hides cached memory connectors until enabled and preserves saved MCP connections", async () => {
const providers = ["mem0", "zep", "supermemory", "cognee", "honcho"];
listGalleryMock.mockResolvedValue({ apps: [...providers, "notion"].map(getAppStoreDefinition) });
const client = await renderBrowse();
for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull();
expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull();
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableMemoryConnectors: true }); });
await flushReact();
for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull();
await act(() => {
client.setQueryData(queryKeys.tools.connections("company-1"), { connections: [connection({ id: "saved", applicationId: "saved-app", config: { sourceTemplateKey: "mem0", connectionMethodKey: "mcp" }, transport: "mcp_remote" })] });
client.setQueryData(queryKeys.tools.applications("company-1"), { applications: [application({ id: "saved-app", name: "Mem0", metadata: { sourceTemplateKey: "mem0" } })] });
client.setQueryData(queryKeys.instance.experimentalSettings, { enableMemoryConnectors: false });
});
await flushReact();
expect(container.textContent).toContain("Mem0");
for (const slug of ["zep", "supermemory", "cognee", "honcho"]) expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull();
});
it("shows all MCP aggregators by default and ignores cached legacy opt-outs", async () => {
const providers = ["zapier", "arcade", "composio", "executor"];
listGalleryMock.mockResolvedValue({ apps: [...providers, "notion"].map(getAppStoreDefinition) });
const client = await renderBrowse();
for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull();
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableMcpAggregators: false }); });
await flushReact();
for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull();
});
it("defaults to tools-only GitHub and hides chat-only catalog and existing chat accounts", async () => {
experimentalMock.mockResolvedValue({});
listGalleryMock.mockResolvedValue({ apps: ["agentmail", "github", "github-code-review-bot", "discord", "telegram", "microsoft-teams"].map(getAppStoreDefinition) });
listApplicationsMock.mockResolvedValue({ applications: [application({
id: "chat-app", type: "chat", name: "Private bot", applicationKey: "chat:github:endpoint-1", metadata: { purpose: "channel" },
})] });
await renderBrowse();
expect(chatListMock).toHaveBeenCalledWith("company-1");
expect(container.querySelector('[data-app-slug="github"]')).not.toBeNull();
for (const slug of ["github-code-review-bot", "discord", "telegram", "microsoft-teams", "slack"]) {
expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull();
}
expect(container.textContent).not.toContain("Private bot");
expect(container.querySelector('[data-app-slug="agentmail"]')).not.toBeNull();
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Connect AgentMail"]')!.click());
expect(navigateMock.mock.lastCall?.[0]).toContain("/apps/chat/connect?provider=agentmail");
expect(container.textContent).not.toContain("Chat with agents");
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Add key GitHub"]')!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github");
const copilot = [...container.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Connect Copilot")!;
expect(copilot).toBeTruthy();
await act(() => void copilot.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github&method=copilot-token");
});
it("names what the card will actually ask for", async () => {
// PAP-659 C4. The verb is derived from the connector's resolved default
// method, so it changes with what this instance can do rather than being a
// fixed string: Notion signs in, GitHub-without-the-cloud-connector wants a
// token, and GitHub with it signs in too.
const github = getAppStoreDefinition("github")!;
listGalleryMock.mockResolvedValue({
apps: [
getAppStoreDefinition("notion"),
{ ...github, ownershipAvailability: { ...github.ownershipAvailability, platform_shared: true } },
],
});
await renderBrowse();
expect(container.querySelector('button[aria-label="Connect Notion"]')).not.toBeNull();
expect(container.querySelector('button[aria-label="Connect GitHub"]')).not.toBeNull();
expect(container.querySelector('button[aria-label="Add key GitHub"]')).toBeNull();
});
it("separates tools and saved bots and gates bots independently of chat connectors", async () => {
listGalleryMock.mockResolvedValue({ apps: ["github", "github-code-review-bot"].map(getAppStoreDefinition) });
listApplicationsMock.mockResolvedValue({ applications: [
application({ id: "github-tools", name: "GitHub", metadata: { sourceTemplateKey: "github" } }),
application({ id: "chat-app", type: "chat", name: "Legacy bot", metadata: { sourceTemplateKey: "github", purpose: "channel" } }),
] });
listConnectionsMock.mockResolvedValue({ connections: [
connection({ id: "github-account", applicationId: "github-tools", name: "My GitHub" }),
connection({ id: "chat-tools", applicationId: "chat-app", connectionPurpose: "channel" }),
] });
chatListMock.mockResolvedValue([
{ id: "endpoint-1", provider: "github", status: "active", assignedAgentName: "Review agent", botLabel: "Review bot", assignedAgentId: "agent-1" },
{ id: "endpoint-2", provider: "github", status: "draft", assignedAgentName: "Draft agent", assignedAgentId: "agent-2" },
]);
const client = await renderBrowse();
const tools = container.querySelector('[data-app-slug="github"]')!;
const bots = container.querySelector('[data-app-slug="github-code-review-bot"]')!;
expect(tools.textContent).toContain("My GitHub");
expect(tools.textContent).not.toContain("Review agent");
expect(bots.textContent).toContain("Review agent · Code review bot");
expect(bots.textContent).toContain("Draft agent");
expect(bots.textContent).not.toContain("My GitHub");
expect(container.textContent).not.toContain("Legacy bot");
await act(() => void tools.querySelector<HTMLButtonElement>('button[aria-label="Add account GitHub"]')!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github&applicationId=github-tools&name=GitHub&new=1");
await act(() => void bots.querySelector<HTMLButtonElement>('button[aria-label="Add connection GitHub Code Review Bot"]')!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&purpose=chat");
const finish = [...bots.querySelectorAll("button")].find((button) => button.textContent === "Finish setup")!;
await act(() => finish.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&purpose=chat&resume=endpoint-2");
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: false, enableGitHubReviewBots: true }); });
await flushReact();
expect(container.querySelector('[data-app-slug="github-code-review-bot"]')).not.toBeNull();
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: true, enableGitHubReviewBots: false }); });
await flushReact();
expect(container.querySelector('[data-app-slug="github-code-review-bot"]')).toBeNull();
expect(container.textContent).not.toContain("Review agent");
expect(container.querySelector('[data-app-slug="github"]')).not.toBeNull();
});
it("keeps the GitHub tool card when the chat catalog needs its local fallback", async () => {
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("github")] });
await renderBrowse();
expect(container.querySelector('[data-app-slug="github"]')).not.toBeNull();
expect(container.querySelector('[data-app-slug="github-code-review-bot"]')).not.toBeNull();
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Add key GitHub"]')!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github");
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Connect GitHub Code Review Bot"]')!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&purpose=chat");
});
it("renders one connector list with the requested header and no gallery sections", async () => {
await renderBrowse();
expect(setBreadcrumbsMock).toHaveBeenCalledWith([{ label: "Connectors" }]);
expect(setBreadcrumbsMock).not.toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ href: "/dashboard" })]),
);
expect(container.querySelector("header")?.textContent).not.toContain(
"Connectors",
);
expect(
container.querySelector('header input[aria-label="Search connectors"]'),
).toBeTruthy();
expect(container.querySelector("header")?.classList).toContain(
"items-center",
);
expect(container.querySelector("header")?.classList).not.toContain(
"justify-end",
);
expect(container.querySelector('[aria-label="Popular apps"]')).toBeNull();
expect(container.querySelector('[aria-label="Connected apps"]')).toBeNull();
expect(container.querySelector('[aria-label="All apps"]')).toBeNull();
expect(
Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="Connector list"] > [data-app-slug]',
),
).map((row) => row.dataset.appSlug),
).toEqual([
"assistant-connection",
"agentmail",
"discord",
"github-code-review-bot",
"imessage-photon",
"jira",
"microsoft-teams",
"notion",
"slack",
"speko",
"telegram",
"custom-mcp",
]);
expect(
container.querySelector('button[aria-label="Connect Jira"]'),
).toBeTruthy();
expect(container.querySelector('[data-app-slug="gmail"]')).toBeNull();
expect(container.textContent).toContain("Connect your own tool");
const customConnect = container.querySelector<HTMLButtonElement>(
'[data-app-slug="custom-mcp"] button',
);
await act(async () => {
customConnect?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Connect your own MCP server");
expect(container.textContent).toContain("Paste a config");
expect(container.textContent).not.toContain("Run your own");
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) =>
button.textContent?.includes("Connect your own MCP server"),
)
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/byo");
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Paste a config"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/advanced/paste-config");
});
it("folds healthy multi-account lists behind a connection-count toggle", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({
connections: [
connection(),
connection({ id: "conn-ops", name: "ops@example.com" }),
connection({ id: "conn-eng", name: "eng@example.com" }),
],
});
await renderBrowse();
const notion = container.querySelector<HTMLElement>('[data-app-slug="notion"]')!;
const toggle = Array.from(notion.querySelectorAll<HTMLButtonElement>("button"))
.find(button => button.textContent?.trim() === "3 connections")!;
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(notion.textContent).not.toContain("ops@example.com");
await clickButton("3 connections", notion);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
for (const name of ["devinfoley@gmail.com", "ops@example.com", "eng@example.com"]) expect(notion.textContent).toContain(name);
await clickButton("3 connections", notion);
expect(notion.textContent).not.toContain("ops@example.com");
await search("ops@example");
expect(container.querySelector('[data-app-slug="notion"]')?.textContent).toContain("ops@example.com");
});
it("reopens connection lists whenever the search query changes", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({
connections: [connection(), connection({ id: "conn-ops", name: "ops@example.com" }),
connection({ id: "conn-eng", name: "eng@example.com" })],
});
await renderBrowse();
await search("ops@example");
let notion = container.querySelector<HTMLElement>('[data-app-slug="notion"]')!;
expect(notion.textContent).toContain("ops@example.com");
await clickButton("3 connections", notion);
expect(notion.textContent).not.toContain("ops@example.com");
await search("eng@example");
notion = container.querySelector<HTMLElement>('[data-app-slug="notion"]')!;
expect(notion.textContent).toContain("eng@example.com");
await search("ops@example");
expect(container.querySelector('[data-app-slug="notion"]')?.textContent).toContain("ops@example.com");
});
it("keeps a single account inline and opens lists that need attention", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({ connections: [connection()] });
await renderBrowse();
let notion = container.querySelector<HTMLElement>('[data-app-slug="notion"]')!;
expect(notion.textContent).toContain("devinfoley@gmail.com");
expect(notion.querySelector('[data-slot="collapsible-trigger"]')).toBeNull();
act(() => root.unmount());
listConnectionsMock.mockResolvedValue({
connections: [connection(), connection({ id: "conn-draft", name: "draft@example.com", status: "draft" })],
});
await renderBrowse();
notion = container.querySelector<HTMLElement>('[data-app-slug="notion"]')!;
expect(notion.textContent).toContain("2 connections· 1 needs attention");
expect(notion.textContent).toContain("draft@example.com");
});
it.each(["provider_setup", "identity_link", undefined, "complete"])(
"keeps unfinished verifying chat setup visible for step %s", async (step) => {
chatListMock.mockResolvedValue([
{ id: "chat-active", provider: "slack", status: "active", assignedAgentName: "Active agent" },
{ id: "chat-verifying", provider: "slack", status: "verifying", assignedAgentName: "Setup agent",
setup: step === undefined ? undefined : { step } },
]);
await renderBrowse();
const slack = container.querySelector<HTMLElement>('[data-app-slug="slack"]')!;
const trigger = slack.querySelector('[data-slot="collapsible-trigger"]')!;
const unfinished = step !== "complete";
expect(trigger.getAttribute("aria-expanded")).toBe(String(unfinished));
expect(slack.textContent?.includes("1 needs attention")).toBe(unfinished);
expect(slack.textContent?.includes("Finish setup")).toBe(unfinished);
if (unfinished) {
await clickButton("Finish setup", slack);
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=slack&purpose=chat&resume=chat-verifying");
}
},
);
it("sorts connected providers first and shows account, owner, status actions, and edit menus inline", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({
connections: [
connection(),
connection({
id: "conn-expired",
name: "ops@example.com",
healthStatus: "error",
healthMessage: "The saved sign-in expired.",
}),
],
});
listUserDirectoryMock.mockResolvedValue({
users: [
{
principalId: "user-1",
status: "active",
user: {
id: "user-1",
name: "Dotta",
email: "dotta@example.com",
image: null,
},
},
],
});
await renderBrowse();
const rows = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="Connector list"] > [data-app-slug]',
),
);
const providers = rows.filter(row => row.dataset.appSlug !== "assistant-connection");
expect(providers[0]?.dataset.appSlug).toBe("notion");
const notion = providers[0]!;
expect(notion.textContent).toContain("devinfoley@gmail.com");
expect(notion.textContent).toContain("ops@example.com");
expect(notion.textContent).not.toContain("Connected by");
expect(notion.textContent).toContain("Dotta");
expect(notion.textContent).toContain("The saved sign-in expired.");
expect(
notion.querySelector('button[aria-label="Add account Notion"]'),
).toBeTruthy();
expect(
notion.querySelector(
'button[aria-label="Manage devinfoley@gmail.com connection"]',
),
).toBeTruthy();
expect(
notion.querySelector(
'button[aria-label="Manage ops@example.com connection"]',
),
).toBeTruthy();
await act(async () => {
notion
.querySelector<HTMLButtonElement>(
'button[aria-label="Open devinfoley@gmail.com permissions"]',
)
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/conn-notion/permissions");
await act(async () => {
notion
.querySelector<HTMLButtonElement>(
'button[aria-label="Add account Notion"]',
)
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith(
"/apps/connect?source=notion&applicationId=app-notion&name=Notion&new=1",
);
const reconnect = Array.from(notion.querySelectorAll("button")).find(
(button) => button.textContent === "Reconnect",
);
await act(async () => {
reconnect?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/conn-expired/permissions");
});
it("removes a connection from the overflow menu only after destructive confirmation", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({ connections: [connection()] });
await renderBrowse();
const menuTrigger = container.querySelector<HTMLButtonElement>(
'button[aria-label="Manage devinfoley@gmail.com connection"]',
);
expect(menuTrigger).toBeTruthy();
await act(async () => {
menuTrigger?.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true }),
);
});
await flushReact();
const removeItem = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === "Remove connection");
expect(removeItem).toBeTruthy();
expect(removeItem?.getAttribute("data-variant")).toBe("destructive");
await act(async () => {
removeItem?.dispatchEvent(new Event("click", { bubbles: true }));
});
await flushReact();
expect(archiveConnectionMock).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
"Remove devinfoley@gmail.com connection?",
);
const confirmButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Remove connection");
expect(confirmButton).toBeTruthy();
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(archiveConnectionMock).toHaveBeenCalledWith("conn-notion");
expect(pushToastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Connection removed",
tone: "success",
}),
);
});
it("retains the confirmed pool revision when a concurrent edit rejects removal", async () => {
const app = aiConnectionRouterAppDefinition("example.pool", { name: "AI connection pool", description: "Use saved connections" });
listGalleryMock.mockResolvedValue({ apps: [app] });
listApplicationsMock.mockResolvedValue({ applications: [application({ name: app.name, metadata: { sourceTemplateKey: app.slug } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ name: "Research", config: { aiRouter: { pluginKey: "example.pool" } } })] });
poolListMock.mockResolvedValue([{ id: "conn-notion", name: "Research", revision: 7 }]);
poolRemoveMock.mockRejectedValue(new Error("Pool changed; reload before deleting"));
await renderBrowse();
await act(() => { container.querySelector<HTMLButtonElement>('button[aria-label="Manage Research connection"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
await act(() => Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(item => item.textContent?.trim() === "Remove connection")!.click());
await flushReact();
expect(document.body.textContent).toContain("The connections in this pool are kept.");
poolListMock.mockResolvedValue([{ id: "conn-notion", name: "Edited elsewhere", revision: 8 }]);
await act(() => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === "Remove connection")!.click());
await flushReact();
expect(poolRemoveMock).toHaveBeenCalledWith("company-1", "conn-notion", 7);
expect(poolListMock).toHaveBeenCalledTimes(1);
expect(archiveConnectionMock).not.toHaveBeenCalled();
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ body: "Pool changed; reload before deleting" }));
});
it("starts each AgentMail Add connection with a distinct setup identity", async () => {
chatListMock.mockResolvedValue([{ id: "chat-draft", provider: "agentmail", status: "draft", assignedAgentName: "Ralph" }]);
await renderBrowse();
const add = container.querySelector<HTMLButtonElement>('button[aria-label="Add connection AgentMail"]')!;
await act(() => add.click());
const first = new URL(navigateMock.mock.lastCall![0], "http://localhost");
expect(first.pathname).toBe("/apps/chat/connect");
expect(first.searchParams.get("provider")).toBe("agentmail");
expect(first.searchParams.get("setupId")).toMatch(/^[0-9a-f-]{36}$/);
await act(() => add.click());
expect(navigateMock.mock.lastCall![0]).not.toBe(first.pathname + first.search);
});
it.each(["slack", "discord", "telegram", "github", "microsoft-teams", "agentmail", "imessage-photon"])(
"puts Manage and removal in the %s chat menu while keeping draft setup visible",
async (provider) => {
chatListMock.mockResolvedValue([
{ id: "chat-active", provider, status: "active", assignedAgentName: "Active agent" },
{ id: "chat-draft", provider, status: "draft", assignedAgentName: "Draft agent" },
{ id: "chat-archived", provider, status: "archived", assignedAgentName: "Removed agent" },
]);
await renderBrowse();
expect(container.textContent).not.toContain("Removed agent");
expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Manage")).toBe(false);
const finish = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Finish setup");
await act(() => finish!.click());
expect(navigateMock).toHaveBeenLastCalledWith(`/apps/chat/connect?provider=${provider}&purpose=chat&resume=chat-draft`);
expect(container.querySelector('button[aria-label^="Manage Draft agent"]')).toBeTruthy();
await act(() => void container.querySelector('button[aria-label^="Manage Active agent"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })));
await flushReact();
const manage = Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find((item) => item.textContent?.trim() === "Manage");
await act(() => manage!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/chat-active/settings");
},
);
it.each(["slack", "agentmail", "discord", "telegram", "microsoft-teams", "github"])("shows agent and connector-owner identities without routine status labels for %s", async provider => {
listAgentsMock.mockResolvedValue([{ id: "maya", name: "Maya", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "cherry-pop" } }]);
listUserDirectoryMock.mockResolvedValue({ users: [{ principalId: "user-1", status: "active", user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: null } }] });
chatListMock.mockResolvedValue([{ id: "chat-avatar", provider, status: "verifying", assignedAgentId: "maya", assignedAgentName: "Maya", sponsorUserId: "user-1", setup: { webhookVerifiedAt: "2026-10-07T12:00:00Z" } }]);
await renderBrowse();
const avatar = container.querySelector('[aria-label="Maya avatar"]');
expect(avatar).not.toBeNull();
const row = avatar!.parentElement!.parentElement!;
expect(row.textContent).toContain("Dotta");
expect(row.textContent).not.toContain("Connected by");
expect(row.textContent).not.toContain("verifying");
expect(row.querySelector('[data-slot="avatar"]')).not.toBeNull();
});
it.each([
["slack", "Slack", "active"], ["slack", "Slack", "draft"],
["agentmail", "AgentMail", "active"], ["agentmail", "AgentMail", "draft"],
])("confirms %s removal for %s %s connections and refreshes the list", async (provider, providerName, status) => {
chatListMock.mockResolvedValue([{ id: "chat-1", provider, status, assignedAgentName: "CEO" }]);
const client = await renderBrowse();
const invalidate = vi.spyOn(client, "invalidateQueries");
await act(() => void container.querySelector(`button[aria-label="Manage CEO ${providerName} connection"]`)!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })));
await flushReact();
const remove = Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find((item) => item.textContent?.trim() === "Remove connection");
await act(() => remove!.click());
await flushReact();
expect(chatSetupMock).not.toHaveBeenCalled();
expect(emailControlMock).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Existing Paperclip tasks and conversation history remain available.");
chatListMock.mockResolvedValue([]);
await act(() => Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Remove connection")!.click());
await flushReact();
if (provider === "agentmail") {
expect(emailControlMock).toHaveBeenCalledWith("chat-1", "remove");
expect(chatSetupMock).not.toHaveBeenCalled();
} else {
expect(chatSetupMock).toHaveBeenCalledWith("chat-1", { action: "remove" });
expect(emailControlMock).not.toHaveBeenCalled();
}
expect(archiveConnectionMock).not.toHaveBeenCalled();
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.chatEndpoints.list("company-1") });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["email-inboxes", "company-1"] });
expect(container.textContent).not.toContain("CEO");
});
it("keeps chat removal open for retry when the server rejects removal", async () => {
chatListMock.mockResolvedValue([{ id: "chat-1", provider: "slack", status: "draft", assignedAgentName: "CEO" }]);
chatSetupMock.mockRejectedValueOnce(new Error("Connection is busy. Try again."));
await renderBrowse();
await act(() => void container.querySelector('button[aria-label="Manage CEO Slack connection"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })));
await flushReact();
await act(() => Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find((item) => item.textContent?.trim() === "Remove connection")!.click());
await flushReact();
await act(() => Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Remove connection")!.click());
await flushReact();
expect(document.querySelector('[role="alertdialog"]')).toBeTruthy();
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "error", body: "Connection is busy. Try again." }));
await act(() => Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Cancel")!.click());
await flushReact();
expect(chatSetupMock).toHaveBeenCalledTimes(1);
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
});
it("keeps an interrupted account visible and resumes setup from its account row", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({
connections: [
connection({
id: "conn-draft",
name: "Notion",
status: "draft",
healthStatus: "unchecked",
}),
],
});
await renderBrowse();
expect(container.textContent).toContain("Setup incomplete");
const finish = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Finish setup",
);
await act(async () => {
finish?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith(
"/apps/connect?source=notion&resume=conn-draft",
);
});
it("resumes a hidden provider only from its existing draft account row", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application({
id: "app-clickup",
name: "ClickUp",
applicationKey: "app-gallery:clickup:one",
metadata: { sourceTemplateKey: "clickup" },
})] });
listConnectionsMock.mockResolvedValue({ connections: [connection({
id: "conn-clickup-draft",
applicationId: "app-clickup",
name: "ClickUp",
status: "draft",
config: { sourceTemplateKey: "clickup" },
})] });
await renderBrowse();
const row = container.querySelector('[data-app-slug="clickup"]');
expect(row).not.toBeNull();
expect(row?.textContent).toContain("Setup incomplete");
const finish = Array.from(row!.querySelectorAll("button")).find(
(button) => button.textContent === "Finish setup",
);
await act(async () => {
finish?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith(
"/apps/connect?source=clickup&resume=conn-clickup-draft",
);
expect(getAppStoreDefinition("clickup")).toBeNull();
});
it("filters the single list without restoring section chrome", async () => {
await renderBrowse();
const input = container.querySelector<HTMLInputElement>(
'input[aria-label="Search connectors"]',
);
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
setter?.call(input, "jira");
input?.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
const rows = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="Connector list"] > [data-app-slug]',
),
);
expect(rows.map((row) => row.dataset.appSlug)).toEqual(["jira"]);
expect(container.textContent).not.toContain("Popular");
expect(container.textContent).not.toContain("All apps");
});
function aiFixture(overrides: Partial<AiManagedConnectionSummary>[] = []) {
let accounts: AiManagedConnectionSummary[] = [
{ id: "openai-sub", grantId: "grant-sub", companyId: "company-1", provider: "openai", method: "subscription", name: "My ChatGPT subscription", ownership: "personal", ownerUserId: "board-user", isDefault: true, canSetDefault: true, status: "connected" },
{ id: "openai-key", grantId: "grant-key", companyId: "company-1", provider: "openai", method: "api_key", name: "My OpenAI API key", ownership: "personal", ownerUserId: "board-user", isDefault: false, canSetDefault: true, status: "connected" },
{ id: "claude-key", grantId: "grant-claude", companyId: "company-1", provider: "anthropic", method: "api_key", name: "My Claude API key", ownership: "personal", ownerUserId: "board-user", isDefault: true, canSetDefault: true, status: "connected" },
].map((account, index) => ({ ...account, ...overrides[index] })) as AiManagedConnectionSummary[];
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("openai"), getAppStoreDefinition("anthropic")] });
listApplicationsMock.mockResolvedValue({ applications: ["openai", "anthropic"].map(provider => application({ id: `app-${provider}`, name: provider, applicationKey: `app-gallery:${provider}:one`, metadata: { sourceTemplateKey: provider } })) });
listConnectionsMock.mockResolvedValue({ connections: accounts.map(account => connection({ id: account.id, companyId: account.companyId, applicationId: `app-${account.provider}`, connectionPurpose: "ai", credentialPolicy: account.ownership === "personal" ? "per_user" : "shared", name: account.name,
config: { sourceTemplateKey: account.provider, ai: { provider: account.provider, method: account.method } } })) });
listAiConnectionsMock.mockImplementation(async () => ({ currentUserId: "board-user", canManageConnections: true, connections: accounts }));
const save = async (_companyId: string, grantId: string) => {
const target = accounts.find(account => account.grantId === grantId)!;
accounts = accounts.map(account => account.provider === target.provider && account.ownerUserId === target.ownerUserId
? { ...account, isDefault: account.grantId === grantId } : account);
return { ok: true };
};
setAiDefaultMock.mockImplementation(save);
return { save, replace: (next: AiManagedConnectionSummary[]) => { accounts = next; }, accounts: () => accounts };
}
async function aiMenu(name = "My OpenAI API key") {
const row = container.querySelector('[data-app-slug="openai"]')!;
if (!row.querySelector(`[aria-label="Manage ${name} connection"]`)) await clickButton("2 connections", row);
await act(() => { row.querySelector(`[aria-label="Manage ${name} connection"]`)!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
return Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(item => item.textContent?.includes("default for OpenAI") || item.textContent === "Default for OpenAI");
}
it("bounds account lookup work when a large provider group expands and saves", async () => {
const fixture = aiFixture();
const count = 400;
let reads = 0;
const accounts = Array.from({ length: count }, (_, index) => ({
...fixture.accounts()[1],
get id() {
if (++reads > count * 30) throw new Error("Account lookup exceeded its linear work budget");
return `large-account-${index}`;
},
grantId: `large-grant-${index}`, name: `OpenAI account ${index}`, isDefault: index === 0,
}));
fixture.replace(accounts);
listConnectionsMock.mockResolvedValue({ connections: accounts.map(account => connection({
id: account.id, companyId: account.companyId, applicationId: "app-openai", connectionPurpose: "ai", credentialPolicy: "per_user", name: account.name,
config: { sourceTemplateKey: "openai", ai: { provider: "openai", method: "api_key" } },
})) });
setAiDefaultMock.mockImplementation(() => new Promise(() => {}));
reads = 0;
await renderBrowse();
const row = container.querySelector('[data-app-slug="openai"]')!;
await clickButton(`${count} connections`, row);
expect(row.querySelectorAll('[aria-label^="Manage OpenAI account"]')).toHaveLength(count);
expect(row.querySelector('[aria-label="Your default for OpenAI"]')).not.toBeNull();
await act(() => { row.querySelector('[aria-label="Manage OpenAI account 1 connection"]')!
.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
const item = Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find(element => element.textContent === "Set as default for OpenAI")!;
await act(() => item.click());
await flushReact();
expect(setAiDefaultMock).toHaveBeenCalledExactlyOnceWith("company-1", "large-grant-1");
expect(reads).toBeLessThanOrEqual(count * 30);
});
it("sets a provider default by grant, keeps the old crown pending, and refetches the saved choice", async () => {
const fixture = aiFixture();
let release!: () => void;
setAiDefaultMock.mockImplementation((company, grant) => new Promise<void>(resolve => { release = () => { void fixture.save(company, grant); resolve(); }; }));
const client = await renderBrowse();
await flushReact();
const item = await aiMenu();
const oldCrown = container.querySelector('[aria-label="Your default for OpenAI"]')!;
expect(oldCrown.parentElement?.textContent).toContain("My ChatGPT subscription");
expect(item?.textContent).toBe("Set as default for OpenAI");
await act(() => item!.click());
await flushReact();
expect(setAiDefaultMock).toHaveBeenCalledExactlyOnceWith("company-1", "grant-key");
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My ChatGPT subscription");
expect((await aiMenu())?.getAttribute("aria-disabled")).toBe("true");
await act(() => release());
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My OpenAI API key");
expect(container.querySelector('[aria-label="Your default for Claude"]')?.parentElement?.textContent).toContain("My Claude API key");
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "success", title: "My OpenAI API key is now your OpenAI default" }));
await act(async () => { await client.invalidateQueries({ queryKey: ["ai-connections", "company-1"] }); });
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My OpenAI API key");
});
it("retains a failed selection and rechecks the same grant before an explicit retry", async () => {
const fixture = aiFixture();
setAiDefaultMock.mockRejectedValueOnce(new Error("Save failed"));
await renderBrowse();
const item = await aiMenu();
await act(() => item!.click());
await flushReact();
expect(container.textContent).toContain("Couldn’t change your OpenAI default");
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My ChatGPT subscription");
const before = listAiConnectionsMock.mock.calls.length;
fixture.replace(fixture.accounts().map(account => account.grantId === "grant-key" ? { ...account, status: "revoked" } : account));
await clickButton("Retry", container);
expect(listAiConnectionsMock.mock.calls.length).toBeGreaterThan(before);
expect(setAiDefaultMock).toHaveBeenCalledTimes(1);
fixture.replace(fixture.accounts().map(account => ({ ...account, status: "connected" })));
await clickButton("Retry", container);
expect(setAiDefaultMock).toHaveBeenCalledTimes(2);
expect(setAiDefaultMock).toHaveBeenLastCalledWith("company-1", "grant-key");
});
it.each([
{ ownership: "shared", ownerUserId: undefined },
{ ownerUserId: "another-person" },
{ canSetDefault: false },
{ routing: { kind: "openrouter", auth: "bearer", models: [] } },
] as Partial<AiManagedConnectionSummary>[])("does not offer personal-default actions for an ineligible account: %j", async override => {
aiFixture([{}, override]);
await renderBrowse();
expect(await aiMenu()).toBeUndefined();
expect(setAiDefaultMock).not.toHaveBeenCalled();
});
it("keeps an unavailable default crowned and disables selection of an unavailable replacement", async () => {
aiFixture([{ status: "needs_attention" }, { status: "expired" }]);
await renderBrowse();
const item = await aiMenu();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My ChatGPT subscription");
expect(container.textContent).toContain("Default unavailable");
expect(item?.getAttribute("aria-disabled")).toBe("true");
expect(item?.textContent).toContain("Reconnect this account");
});
it("refetches cross-tab changes on focus and drops the previous user's crowns on an identity change", async () => {
const fixture = aiFixture();
const client = await renderBrowse();
await aiMenu();
await act(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); });
await fixture.save("company-1", "grant-key");
await act(() => { focusManager.setFocused(false); focusManager.setFocused(true); });
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My OpenAI API key");
accountIdentity.userId = "another-person";
listAiConnectionsMock.mockImplementation(() => new Promise(() => {}));
await act(() => { root.render(<QueryClientProvider client={client}><TooltipProvider><Browse /></TooltipProvider></QueryClientProvider>); });
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')).toBeNull();
expect(container.querySelector('[aria-label="Your default for Claude"]')).toBeNull();
focusManager.setFocused(undefined);
});
it("shows existing accounts and an actionable warning when the gallery request fails", async () => {
listGalleryMock.mockRejectedValue(new Error("Gallery unavailable"));
listApplicationsMock.mockResolvedValue({
applications: [
application({
id: "custom-app",
name: "Internal search",
applicationKey: "custom:search",
metadata: { source: "link" },
}),
],
});
listConnectionsMock.mockResolvedValue({
connections: [
connection({
id: "custom-connection",
applicationId: "custom-app",
name: "search.internal.example",
config: {},
}),
],
});
await renderBrowse();
expect(container.querySelector('[data-query-view="error"]')?.textContent).toContain(
"Couldn't load every connector",
);
expect(container.textContent).toContain("Internal search");
expect(container.textContent).toContain("search.internal.example");
expect(
Array.from(container.querySelectorAll("button")).some(
(button) => button.textContent === "Retry",
),
).toBe(true);
});
});