mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Fix native pool connector wiring and cover full-app CRUD
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e15923bb83
commit
c152cd9d85
8 files changed
+186
-11
No files matched your search
@@ -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" }));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user