paperclip/tests/e2e/app-not-connected.spec.ts

175 lines
7.8 KiB
TypeScript

import { expect, test, type APIRequestContext } from "@playwright/test";
import { createServer, type Server } from "node:http";
import { listenOnFetchAllowedPort } from "./fetch-allowed-port";
// Not-connected apps keep their identity on the Permissions page. Reconnecting
// must revive the same application/connection, not duplicate it.
const SCREENSHOT_DIR = "test-results";
type Seed = { companyId: string; prefix: string };
async function newCompany(request: APIRequestContext, label: string): Promise<Seed> {
const res = await request.post("/api/companies", { data: { name: `Apps navigation ${label} ${Date.now()}` } });
expect(res.ok(), `create company failed ${res.status()}: ${await res.text()}`).toBe(true);
const company = await res.json();
return {
companyId: company.id,
prefix: company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E",
};
}
type MockMcpServer = { url: string; close: () => Promise<void> };
async function startMockMcp(): Promise<MockMcpServer> {
const server: Server = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
let payload: { id?: string | number; method?: string } = {};
try {
payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
} catch {
// fall through
}
if (payload.method === "tools/list") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: payload.id ?? null,
result: {
tools: [
{
name: "list_widgets",
title: "List widgets",
description: "Read-only listing of widgets.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
],
},
}),
);
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ jsonrpc: "2.0", id: payload.id ?? null, result: {} }));
});
const port = await listenOnFetchAllowedPort(server);
return {
url: `http://127.0.0.1:${port}`,
close: () => new Promise((resolve) => server.close(() => resolve())),
};
}
test.describe.serial("not-connected app page", () => {
test.setTimeout(240_000);
let mock: MockMcpServer;
let seed: Seed;
let applicationId: string;
let connectionId: string;
test.beforeAll(async ({ request }) => {
mock = await startMockMcp();
seed = await newCompany(request, "app-page");
const connect = await request.post(`/api/companies/${seed.companyId}/tools/apps/connect`, {
data: { link: mock.url, name: "Bla", credentialValues: { "credentials.authorization": "qa-token" } },
});
expect(connect.ok(), `connect failed ${connect.status()}: ${await connect.text()}`).toBe(true);
const body = await connect.json();
connectionId = body.connectionId as string;
applicationId = body.application.id as string;
// Archive the connection (Remove app), then resurrect the application so
// its connector card offers a fresh Connect action.
const archive = await request.delete(`/api/tool-connections/${connectionId}`);
expect(archive.ok(), `archive failed ${archive.status()}: ${await archive.text()}`).toBe(true);
const revive = await request.patch(`/api/tool-applications/${applicationId}`, {
data: { status: "active" },
});
expect(revive.ok(), `revive failed ${revive.status()}: ${await revive.text()}`).toBe(true);
});
test.afterAll(async () => {
await mock?.close();
});
test("not-connected row opens the app page, not the generic wizard", async ({ page }) => {
await page.goto(`/${seed.prefix}/apps/connections`);
const row = page
.getByRole("list", { name: "Connector list" })
.getByRole("listitem")
.filter({ has: page.getByRole("heading", { name: "Bla", exact: true }) });
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toHaveAttribute("data-connected", "false");
const connectButton = row.getByRole("button", { name: "Connect Bla" });
await expect(connectButton).toBeVisible();
await connectButton.click();
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/app/${applicationId}/permissions$`), { timeout: 20_000 });
await expect(page.getByRole("heading", { name: "Bla" })).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole("heading", { name: "Needs attention" })).toBeVisible();
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-01-app-not-connected.png`, fullPage: true });
});
test("reconnect prefills the wizard and revives the same application", async ({ page, request }) => {
await page.goto(`/${seed.prefix}/apps/app/${applicationId}`);
await page.getByRole("button", { name: "Reconnect", exact: true }).click();
await expect(page).toHaveURL(/\/apps\/connect\?/, { timeout: 20_000 });
await expect(page.getByText("Connect your own MCP server")).toBeVisible({ timeout: 20_000 });
await page.getByRole("button", { name: "Save and continue" }).click();
await expect(page.getByText(mock.url)).toBeVisible();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-02-reconnect-prefilled.png`, fullPage: true });
await page.getByRole("button", { name: "Check link" }).click();
// Reconnect retains the previous identity and application, so the generic
// check can commit the restored connection transactionally.
await expect(page.getByRole("heading", { name: "Bla is ready." })).toBeVisible({ timeout: 30_000 });
const apps = await request.get(`/api/companies/${seed.companyId}/tools/applications`);
const appsBody = await apps.json();
const matching = (appsBody.applications as Array<{ id: string; name: string }>).filter(
(app) => app.name === "Bla",
);
expect(matching).toHaveLength(1);
expect(matching[0].id).toBe(applicationId);
const conns = await request.get(`/api/companies/${seed.companyId}/tools/connections`);
const connsBody = await conns.json();
const appConns = (connsBody.connections as Array<{ id: string; applicationId: string; status: string }>).filter(
(c) => c.applicationId === applicationId,
);
expect(appConns).toHaveLength(1);
expect(appConns[0].id).toBe(connectionId);
expect(appConns[0].status).not.toBe("archived");
});
test("archived app connection returns to Permissions with reconnect", async ({ page, request }) => {
const archive = await request.delete(`/api/tool-connections/${connectionId}`);
expect(archive.ok(), `archive failed ${archive.status()}: ${await archive.text()}`).toBe(true);
const revive = await request.patch(`/api/tool-applications/${applicationId}`, { data: { status: "active" } });
expect(revive.ok(), `revive failed ${revive.status()}: ${await revive.text()}`).toBe(true);
await page.goto(`/${seed.prefix}/apps/app/${applicationId}`);
await expect(page).toHaveURL(
new RegExp(`/${seed.prefix}/apps/app/${applicationId}/permissions$`),
{ timeout: 20_000 },
);
await expect(page.getByText("Not connected", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Needs attention" })).toBeVisible();
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible();
await page.goto(`/${seed.prefix}/apps/connections`);
const row = page
.getByRole("list", { name: "Connector list" })
.getByRole("listitem")
.filter({ has: page.getByRole("heading", { name: "Bla", exact: true }) });
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByRole("button", { name: "Connect Bla" })).toBeVisible();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-03-reconnected-row.png`, fullPage: true });
});
});