e2e/agent-mcp.spec.ts
Rama main · réplica de lectura del repositorio original.
import { test, expect, type Page } from "@playwright/test";
import { TestApiClient } from "./fixtures";
import { waitForPageText } from "./helpers";
// Stage 3.2 (MUL-3870): the creator-only MCP tab on the agent detail page.
//
// Auth + workspace bootstrap go through the real backend (same as every other
// spec), but the agent list and the Composio connection/catalog endpoints are
// mocked at the network boundary so the test runs without a configured
// COMPOSIO_API_KEY or a live runtime to bind an agent to. The PUT /api/agents
// write is intercepted so we can assert the exact allowlist body the toggle
// produces — the heart of the data contract — instead of depending on the
// backend persisting it.
const E2E_WORKER =
process.env.TEST_PARALLEL_INDEX ?? process.env.TEST_WORKER_INDEX ?? "0";
const E2E_RUN_ID =
process.env.E2E_RUN_ID ?? `${Date.now().toString(36)}-${process.pid.toString(36)}`;
const EMAIL = `e2e-mcp-${E2E_WORKER}-${E2E_RUN_ID}@multica.ai`;
const NAME = "E2E MCP User";
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_USER_ID = "99999999-9999-4999-8999-999999999999";
interface SetupResult {
slug: string;
userId: string;
}
/** Log in via the real API, capture the authed user id, inject the token, and
* return the workspace slug + user id so the test can mock an agent owned
* (or not) by this exact user. */
async function loginCapturingUser(page: Page): Promise<SetupResult> {
const api = new TestApiClient();
const data = await api.login(EMAIL, NAME);
const userId: string | undefined = data?.user?.id;
if (!userId) throw new Error("login did not return a user id");
const workspace = await api.ensureWorkspace(
`E2E MCP WS ${E2E_WORKER}`,
`e2e-mcp-${E2E_WORKER}-${E2E_RUN_ID}`,
);
await api.markUserOnboarded();
const token = api.getToken();
if (!token) throw new Error("login did not return a token");
await page.addInitScript((t) => {
localStorage.setItem("multica_token", t);
localStorage.setItem("multica:chat:isOpen", "false");
}, token);
return { slug: workspace.slug, userId };
}
function mockAgent(ownerId: string, workspaceId: string) {
return {
id: AGENT_ID,
workspace_id: workspaceId,
runtime_id: "22222222-2222-4222-8222-222222222222",
name: "MCP Test Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_args: [],
visibility: "workspace",
status: "idle",
max_concurrent_tasks: 1,
model: "",
owner_id: ownerId,
skills: [],
created_at: "2026-06-30T00:00:00Z",
updated_at: "2026-06-30T00:00:00Z",
archived_at: null,
archived_by: null,
composio_toolkit_allowlist: [],
};
}
/** Mock the Composio catalog + the current user's active connections (Notion +
* Slack), the agent list (owned by `ownerId`), and capture any PUT
* /api/agents/<id> body. Returns a getter for the last captured allowlist. */
async function mockApis(page: Page, ownerId: string) {
const captured: { allowlist?: unknown } = {};
await page.route("**/api/integrations/composio/toolkits", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ slug: "notion", name: "Notion", connectable: true },
{ slug: "slack", name: "Slack", connectable: true },
]),
}),
);
await page.route("**/api/integrations/composio/connections", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: "conn-notion",
toolkit_slug: "notion",
status: "active",
connected_at: "2026-06-30T00:00:00Z",
last_used_at: null,
},
{
id: "conn-slack",
toolkit_slug: "slack",
status: "active",
connected_at: "2026-06-30T00:00:00Z",
last_used_at: null,
},
]),
}),
);
// One handler for both the list (GET, query string) and the write
// (PUT /api/agents/<id>). Other agent sub-routes fall through.
await page.route("**/api/agents**", (route) => {
const req = route.request();
const url = new URL(req.url());
const workspaceId = url.searchParams.get("workspace_id") ?? "ws-mock";
if (req.method() === "PUT" && url.pathname.endsWith(`/api/agents/${AGENT_ID}`)) {
const body = req.postDataJSON?.() ?? {};
captured.allowlist = body.composio_toolkit_allowlist;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...mockAgent(ownerId, workspaceId),
composio_toolkit_allowlist: body.composio_toolkit_allowlist ?? [],
}),
});
}
if (req.method() === "GET" && url.pathname.endsWith("/api/agents")) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([mockAgent(ownerId, workspaceId)]),
});
}
return route.fallback();
});
return () => captured.allowlist;
}
test.describe("Agent MCP tab (creator-only)", () => {
test("creator sees the MCP Apps tab and toggling a toolkit writes the allowlist", async ({
page,
}) => {
const { slug, userId } = await loginCapturingUser(page);
const getAllowlist = await mockApis(page, userId);
await page.goto(`/${slug}/agents/${AGENT_ID}`, {
waitUntil: "domcontentloaded",
});
await waitForPageText(page, "MCP Test Agent");
// The creator-only tab entry is present and opens the connection list.
const tab = page.getByRole("button", { name: "MCP Apps" });
await expect(tab).toBeVisible({ timeout: 15000 });
await tab.click();
await expect(page.getByText("Notion")).toBeVisible();
await expect(page.getByText("Slack")).toBeVisible();
// Allow Notion → the PUT body carries exactly ["notion"].
await page.getByLabel(/Allow Notion for this agent/i).click();
await expect.poll(() => getAllowlist()).toEqual(["notion"]);
});
test("a non-creator viewer does not see the MCP Apps tab", async ({ page }) => {
const { slug } = await loginCapturingUser(page);
// Agent owned by someone else → the creator gate hides the tab entry.
await mockApis(page, OTHER_USER_ID);
await page.goto(`/${slug}/agents/${AGENT_ID}`, {
waitUntil: "domcontentloaded",
});
await waitForPageText(page, "MCP Test Agent");
// Other tabs render, but the creator-only MCP Apps entry must not.
await expect(page.getByRole("button", { name: "Activity" })).toBeVisible({
timeout: 15000,
});
await expect(page.getByRole("button", { name: "MCP Apps" })).toHaveCount(0);
});
});