Fix native pool connector wiring and cover full-app CRUD

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-05 12:26:41 -05:00
1 parent e15923bb83
commit c152cd9d85
8 files changed
+186 -11

No files matched your search

+20
View File
@@ -94,3 +94,23 @@ Core also owns **Connectors / Pool host** stories for the generic native
extension. The overview links to Cloud's **In Connectors** preview on port 6010;
its stories mount these same production routes, header, sidebar and tokens.
**Full Setup And Management** exercises the complete catalog-to-pool journey.
## Full-app browser acceptance
Install the compatible plugin into an isolated, loopback `local_trusted` instance,
enable the experimental flag, and use a company named for an E2E or test drive
with two saved, authorized AI accounts. Run:
```sh
PAPERCLIP_CONNECTION_POOL_E2E=1 \
AI_CONNECTIONS_TEST_URL=http://127.0.0.1:3100 \
AI_CONNECTIONS_TEST_COMPANY_ID=<test-company-id> \
pnpm exec playwright test --config tests/ai-connections-app/playwright.config.ts connection-pools.spec.ts
```
These opt-in tests exercise the shipped app, installed plugin and database without
mocking browser routes or pool APIs. They cover catalog navigation, setup, ordering,
paused defaults, rename, member changes, refresh persistence, stale edits and
removal. Cleanup archives only the test's own pool and verifies that existing
accounts and pools remain intact. This suite does not execute agents or probe
live usage; provider and restart acceptance remain separate gated test drives.
@@ -0,0 +1,129 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import type { AiConnectionList, AiConnectionPool, AppDefinition } from "@paperclipai/shared";
// Exercise the full shipped app and an installed router, with real persistence.
// No browser routes, credentials, providers, or pool APIs are mocked. Opt in
// only against a loopback test-drive company with existing saved accounts.
const companyId = process.env.AI_CONNECTIONS_TEST_COMPANY_ID;
test.skip(process.env.PAPERCLIP_CONNECTION_POOL_E2E !== "1" || !companyId, "Opt in with an isolated connection-pool test drive");
let prefix: string;
let connector: AppDefinition;
let accounts: AiConnectionList["connections"];
const poolsPath = `/api/companies/${companyId}/ai-connection-pools`;
async function listPools(request: APIRequestContext): Promise<AiConnectionPool[]> {
const response = await request.get(poolsPath);
expect(response.ok()).toBe(true);
return response.json();
}
test.beforeAll(async ({ request }, testInfo) => {
const origin = new URL(testInfo.project.use.baseURL!);
expect(origin.protocol).toBe("http:");
expect(["127.0.0.1", "[::1]"]).toContain(origin.hostname);
const health = await (await request.get("/api/health")).json();
expect(health).toMatchObject({ status: "ok", bootstrapStatus: "ready", deploymentMode: "local_trusted" });
const company = await (await request.get(`/api/companies/${companyId}`)).json();
expect(company.name).toMatch(/test drive|e2e/i);
prefix = company.issuePrefix;
const gallery = await (await request.get(`/api/companies/${companyId}/tools/gallery`)).json();
connector = gallery.apps.find((app: AppDefinition) => app.aiConnectionRouter && app.availability?.available);
expect(connector, "Install and enable a router plugin, then opt in to the instance flag").toBeTruthy();
const list: AiConnectionList = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json();
expect(list.canManageConnections).toBe(true);
accounts = list.connections.filter((account, index, all) => account.status === "connected" && all.findIndex(candidate => candidate.id === account.id) === index).slice(0, 2);
expect(accounts, "Use two pre-existing authorized accounts").toHaveLength(2);
});
test("native catalog setup, account edits, conflicts and removal persist through the full app", async ({ page, request }) => {
test.setTimeout(90_000);
const originalPools = await listPools(request);
const originalAccountIds = (await (await request.get(`/api/companies/${companyId}/ai-connections`)).json()).connections.map((account: { id: string }) => account.id).sort();
let poolId: string | undefined;
try {
await page.goto(`/${prefix}/dashboard`);
await page.getByRole("link", { name: "Connectors", exact: true }).click();
await page.getByRole("button", { name: `Add connection pool ${connector.name}`, exact: true }).click();
await expect(page.getByRole("heading", { name: "Add a connection pool", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeDisabled();
await expect(page.getByRole("link", { name: "Connect a new account", exact: true })).toHaveAttribute("target", "_blank");
for (const account of accounts) await page.getByRole("checkbox", { name: account.name, exact: true }).check();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: `Move ${accounts[1]!.name} up`, exact: true }).click();
await page.getByRole("button", { name: "Back", exact: true }).click();
for (const account of accounts) await expect(page.getByRole("checkbox", { name: account.name, exact: true })).toBeChecked();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.getByRole("list", { name: "Connection order" }).getByRole("listitem").first()).toContainText(accounts[1]!.name);
await page.getByRole("button", { name: "Create pool", exact: true }).click();
await expect(page).toHaveURL(/\/apps\/[a-f0-9-]+\/permissions$/);
poolId = new URL(page.url()).pathname.split("/").at(-2)!;
const saved = async () => (await listPools(request)).find(pool => pool.id === poolId)!;
expect(await saved()).toMatchObject({ enabled: false, mode: "round_robin", thresholdPercent: 90 });
expect((await saved()).members.map(member => member.binding.connectionId)).toEqual([accounts[1]!.id, accounts[0]!.id]);
await expect(page.getByRole("link", { name: "Settings", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Review", exact: true })).toHaveCount(0);
await expect(page.getByRole("checkbox", { name: "Enable this pool", exact: true })).not.toBeChecked();
const name = `Pool E2E ${Date.now()}`;
await page.getByRole("button", { name: "Rename app", exact: true }).click();
await page.getByRole("textbox", { name: "App name", exact: true }).fill(name);
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("heading", { name, level: 1 })).toBeVisible();
await page.getByRole("button", { name: `Remove ${accounts[0]!.name}`, exact: true }).click();
await page.getByRole("button", { name: "Save changes", exact: true }).click();
await expect.poll(async () => (await saved()).members.length).toBe(1);
await page.reload();
await expect(page.getByRole("list", { name: "Connection order" }).getByRole("listitem")).toHaveCount(1);
await page.getByRole("button", { name: "Add connections", exact: true }).click();
const picker = page.getByRole("dialog", { name: "Add connections", exact: true });
await picker.getByRole("checkbox", { name: accounts[0]!.name, exact: true }).check();
await picker.getByRole("button", { name: "Done", exact: true }).click();
await page.getByRole("checkbox", { name: "Enable this pool", exact: true }).check();
await page.getByRole("button", { name: "Save changes", exact: true }).click();
await expect.poll(async () => (await saved()).enabled).toBe(true);
await page.reload();
await expect(page.getByRole("heading", { name, level: 1 })).toBeVisible();
await expect(page.getByRole("checkbox", { name: "Enable this pool", exact: true })).toBeChecked();
await expect(page.getByRole("list", { name: "Connection order" }).getByRole("listitem")).toHaveCount(2);
// Two real pages keep their reviewed revisions. A stale save must not
// overwrite a newer edit, and refreshing must recover the latest version.
const other = await page.context().newPage();
await other.goto(page.url());
await expect(other.getByRole("heading", { name, level: 1 })).toBeVisible();
await page.getByRole("checkbox", { name: "Enable this pool", exact: true }).uncheck();
await page.getByRole("button", { name: "Save changes", exact: true }).click();
await expect.poll(async () => (await saved()).enabled).toBe(false);
await other.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(other.getByRole("alert")).toContainText(/changed|reload/i);
expect((await saved()).enabled).toBe(false);
await other.close();
await page.getByRole("button", { name: "Manage connection pool", exact: true }).click();
await page.getByRole("menuitem", { name: "Remove connection", exact: true }).click();
const confirmation = page.getByRole("alertdialog");
await expect(confirmation).toContainText("The connections in this pool are kept.");
await confirmation.getByRole("button", { name: "Cancel", exact: true }).click();
expect(await saved()).toBeTruthy();
await page.getByRole("link", { name: "All connectors", exact: true }).click();
await page.getByRole("button", { name: `Manage ${name} connection`, exact: true }).click();
await page.getByRole("menuitem", { name: "Remove connection", exact: true }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Remove connection", exact: true }).click();
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect.poll(async () => (await listPools(request)).map(pool => pool.id).sort()).toEqual(originalPools.map(pool => pool.id).sort());
await expect(page.getByRole("button", { name: `Open ${name} permissions`, exact: true })).toHaveCount(0);
const remaining = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json();
expect(remaining.connections.map((account: { id: string }) => account.id).sort()).toEqual(originalAccountIds);
} finally {
// Cleanup only this test's own disposable pool, through the normal API.
if (poolId) {
const pool = (await listPools(request)).find(pool => pool.id === poolId);
if (pool) expect((await request.delete(`${poolsPath}/${poolId}`, { data: { expectedRevision: pool.revision } })).ok()).toBe(true);
}
}
});
test("an unavailable dynamic router stays on an actionable setup page", async ({ page }) => {
await page.goto(`/${prefix}/apps/connect?${new URLSearchParams({ source: "ai-router-0000" })}`);
await expect(page.getByRole("alert")).toContainText("This connection pool plugin is unavailable. Enable it in Plugins.");
await expect(page).toHaveURL(/\/apps\/connect\?/);
});
@@ -1,6 +1,6 @@
import { ChevronLeft } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { aiConnectionRouterPluginKey, aiConnectionRouterSlug, humanizeConnectionDisplayName } from "@paperclipai/shared";
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
import type { ToolApplication, ToolConnection } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { toolsApi } from "@/api/tools";
@@ -58,7 +58,6 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
});
const connection = connectionQuery.data;
const poolPluginKey = connection && aiConnectionRouterPluginKey(connection);
const application = props.kind === "application"
? (applicationsQuery.data?.applications ?? []).find((app) => app.id === props.applicationId)
: null;
@@ -101,11 +100,11 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
<nav className="scrollbar-auto-hide min-h-0 flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-0.5">
{APP_TABS.filter(tab => !poolPluginKey || tab.key === "permissions").map((tab) => (
{APP_TABS.map((tab) => (
<SidebarNavItem
key={tab.key}
to={tabHref(props, tab.key)}
label={poolPluginKey ? "Settings" : tab.label}
label={tab.label}
icon={tab.icon}
end
badge={tab.key === "review" && reviewCount > 0 ? reviewCount : undefined}
@@ -130,8 +129,6 @@ function galleryEntryFor(
connection: ToolConnection | undefined,
application: ToolApplication | undefined,
): AppGalleryDisplayEntry | null {
const router = connection && aiConnectionRouterPluginKey(connection);
if (router) return apps.find(app => app.slug === aiConnectionRouterSlug(router)) ?? null;
if (application?.applicationKey) {
const keyed = apps.find((app) => appDefinitionSlug(app) === application.applicationKey);
if (keyed) return keyed;
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppDetailSidebar } from "./AppConnectionSidebar";
import { aiConnectionRouterAppDefinition } from "@paperclipai/shared";
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const currentPath = vi.hoisted(() => ({ value: "/apps/conn-1/permissions" }));
@@ -207,6 +208,18 @@ describe("AppConnectionSidebar", () => {
expect(container.querySelector('[data-to="/apps/conn-1/setup"]')).toBeNull();
});
it("shows only pool settings and keeps connector branding after a pool is renamed", async () => {
const entry = aiConnectionRouterAppDefinition("example.pool", { name: "AI connection pool", description: "Use saved connections" });
mockToolsApi.getConnection.mockResolvedValue(connection({ name: "Research accounts", config: { aiRouter: { pluginKey: "example.pool" } } }));
mockToolsApi.listApplications.mockResolvedValue({ applications: [] });
mockToolsApi.listGallery.mockResolvedValue({ apps: [entry] });
await renderSidebar();
expect(container.querySelectorAll("[data-to]")).toHaveLength(1);
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/permissions", label: "Settings", end: true }));
expect(container.querySelector("[data-app-logo]")?.getAttribute("data-logo-url")).toBe(entry.branding.logoUrl);
expect(container.textContent).not.toContain("Review");
});
it("uses the application key for a customized connection display name", async () => {
mockToolsApi.getConnection.mockResolvedValue(connection({ name: "Dotta's GitHub" }));
+6 -3
View File
@@ -1,6 +1,6 @@
import { ChevronLeft } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
import { aiConnectionRouterPluginKey, aiConnectionRouterSlug, humanizeConnectionDisplayName } from "@paperclipai/shared";
import type { ToolApplication, ToolConnection } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { toolsApi } from "@/api/tools";
@@ -61,6 +61,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
});
const connection = connectionQuery.data;
const poolPluginKey = connection && aiConnectionRouterPluginKey(connection);
const applicationId = props.kind === "application" ? props.applicationId : connection?.applicationId;
const application = (applicationsQuery.data?.applications ?? []).find((app) => app.id === applicationId) ?? null;
const appConnections = props.kind === "application"
@@ -112,11 +113,11 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
<nav className="scrollbar-auto-hide min-h-0 flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-0.5">
{APP_TABS.map((tab) => (
{APP_TABS.filter(tab => !poolPluginKey || tab.key === "permissions").map((tab) => (
<SidebarNavItem
key={tab.key}
to={tabHref(props, tab.key)}
label={tab.label}
label={poolPluginKey ? "Settings" : tab.label}
icon={tab.icon}
end
badge={tab.key === "review" && reviewCount > 0 ? reviewCount : undefined}
@@ -141,6 +142,8 @@ function galleryEntryFor(
connection: ToolConnection | undefined,
application: ToolApplication | undefined,
): AppGalleryDisplayEntry | null {
const router = connection && aiConnectionRouterPluginKey(connection);
if (router) return apps.find(app => appDefinitionSlug(app) === aiConnectionRouterSlug(router)) ?? null;
const sourceSlug = appApplicationSourceSlug(application) ?? appConnectionSourceSlug(connection);
if (sourceSlug) {
const keyed = apps.find((app) => appDefinitionSlug(app) === sourceSlug);
+9 -1
View File
@@ -1,4 +1,4 @@
import { APP_STORE_DEFINITIONS, appSupportsCatalogSetup } from "@paperclipai/shared";
import { APP_STORE_DEFINITIONS, aiConnectionRouterAppDefinition, appSupportsCatalogSetup } from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import {
MCP_DIRECT_OAUTH_CONNECT_SLUGS,
@@ -63,6 +63,14 @@ describe("app connect policy", () => {
expect(vercelConnectSourceHref("notion")).toBe("/apps/vercel-connect?source=notion");
});
it("lets installed router connectors reach host catalog validation", () => {
const pool = aiConnectionRouterAppDefinition("example.pool", { name: "AI connection pool", description: "Use saved connections" });
const params = new URL(appSourceConnectHref(pool.slug), "http://paperclip.test").searchParams;
expect(canEnterAppsConnect(params)).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=ai-router-invalid"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=ai-router-1"))).toBe(false);
});
it("routes every capability-backed catalog definition through its source deep link", () => {
const connectableApps = APP_STORE_DEFINITIONS.filter(appSupportsCatalogSetup);
+5
View File
@@ -67,6 +67,11 @@ export function canEnterAppsConnect(
);
}
const source = searchParams.get("source") ?? "";
// Router connectors are registered by installed plugins, not the static
// catalog. AppsConnect resolves this reserved slug against the host gallery
// before rendering the pool wizard; unknown/uninstalled routers show its
// actionable unavailable state rather than bouncing back to the catalog.
if (/^ai-router-(?:[a-f0-9]{2})+$/.test(source)) return true;
const entry = getAppStoreDefinition(source);
if (
!chatConnectorsEnabled &&
@@ -5,7 +5,7 @@ import { aiConnectionRouterAppDefinition, aiConnectionRouterSlug, getAppStoreDef
import { Browse } from "@/pages/apps/Browse";
import { AppsConnect } from "@/pages/apps/AppsConnect";
import { AppDetail } from "@/pages/apps/AppDetail";
import { AppDetailSidebar } from "@/components/AppConnectionSidebar.production";
import { AppDetailSidebar } from "@/components/AppConnectionSidebar";
import { BreadcrumbBar } from "@/components/BreadcrumbBar";
export type PoolConnectorScenario = "catalog" | "setup" | "manage" | "empty" | "unavailable" | "permission" | "conflict" | "usage" | "exhausted" | "unknown" | "revoked";