paperclip/tests/e2e/connection-intents.spec.ts

376 lines
13 KiB
TypeScript

import { expect, test, type APIRequestContext } from "@playwright/test";
import { createServer, type Server } from "node:http";
import { listenOnFetchAllowedPort } from "./fetch-allowed-port";
type Json = Record<string, unknown>;
type Seed = { companyId: string; prefix: string };
type Agent = { id: string; name: string };
async function json<T = Json>(
response: Awaited<ReturnType<APIRequestContext["get"]>>,
): Promise<T> {
expect(
response.ok(),
`${response.url()} failed ${response.status()}: ${await response.text()}`,
).toBe(true);
return (await response.json()) as T;
}
async function newCompany(request: APIRequestContext): Promise<Seed> {
const company = await json<{ id: string; issuePrefix: string }>(
await request.post("/api/companies", {
data: { name: `Connection intent E2E ${Date.now()}` },
}),
);
return { companyId: company.id, prefix: company.issuePrefix };
}
async function createAgent(
request: APIRequestContext,
companyId: string,
name: string,
): Promise<Agent> {
return await json<Agent>(
await request.post(`/api/companies/${companyId}/agents`, {
data: {
name,
role: "qa",
title: "Connection intent fixture agent",
capabilities: "Exercises deterministic connection intent wiring.",
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: ["--input-type=module", "-e", "process.exit(0)"],
},
},
}),
);
}
async function startFakeProvider() {
const captures: Array<{ method: string; toolName: string | null }> = [];
const server: Server = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const payload = JSON.parse(
Buffer.concat(chunks).toString("utf8") || "{}",
) as {
id?: string | number;
method?: string;
params?: { name?: string };
};
captures.push({
method: String(payload.method ?? "<unknown>"),
toolName: payload.params?.name ?? null,
});
res.writeHead(200, { "Content-Type": "application/json" });
if (payload.method === "tools/list") {
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: payload.id ?? null,
result: {
tools: [
{
name: "notion:list_pages",
title: "List fixture pages",
description:
"Reads deterministic pages from the fake Notion provider.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
},
],
},
}),
);
return;
}
if (payload.method === "tools/call") {
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: payload.id ?? null,
result: {
content: [{ type: "text", text: "Fixture page inventory" }],
},
}),
);
return;
}
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}/`,
captures,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}
function connectionAwareScript(connectionId: string) {
return `
const post = async (url, body, token = process.env.PAPERCLIP_RUNTIME_TOOLS_TOKEN) => {
const response = await fetch(url, {
method: "POST",
headers: { authorization: \`Bearer \${token}\`, "content-type": "application/json" },
body: JSON.stringify(body)
});
if (!response.ok) throw new Error(\`\${response.status}: \${await response.text()}\`);
return await response.json();
};
const search = await post(process.env.PAPERCLIP_RUNTIME_TOOLS_CONNECTIONS_SEARCH_URL, { query: "notion" });
const notion = search.results.find((result) => result.service === "notion");
if (!notion) throw new Error("Notion was not advertised");
if (notion.state !== "ready") {
const requested = await post(process.env.PAPERCLIP_RUNTIME_TOOLS_CONNECTION_REQUEST_URL, { service: notion.service });
if (requested.state !== "needs_user_action") throw new Error("Expected a user-action request");
console.log("waiting for connection intent");
process.exit(0);
}
const apiHeaders = { authorization: \`Bearer \${process.env.PAPERCLIP_API_KEY}\`, "content-type": "application/json" };
const sessionResponse = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/sessions\`, {
method: "POST",
headers: apiHeaders,
body: JSON.stringify({ runId: process.env.PAPERCLIP_RUN_ID, ttlMs: 60000 })
});
if (!sessionResponse.ok) throw new Error(await sessionResponse.text());
const session = await sessionResponse.json();
const toolsResponse = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/tools\`, {
headers: { "x-paperclip-tool-gateway-token": session.token }
});
const tools = await toolsResponse.json();
const tool = tools.find((entry) => entry.connectionId === ${JSON.stringify(connectionId)} && entry.upstreamToolName === "notion:list_pages");
if (!tool) throw new Error("Continuation did not receive the installed Notion tool");
const call = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/tools/call\`, {
method: "POST",
headers: { "x-paperclip-tool-gateway-token": session.token, "content-type": "application/json" },
body: JSON.stringify({ tool: tool.name, parameters: {} })
});
if (!call.ok) throw new Error(await call.text());
console.log(await call.text());
`;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function waitForAgentRun(
request: APIRequestContext,
companyId: string,
agentId: string,
) {
let terminalRun: { id: string; status: string } | null = null;
await expect
.poll(
async () => {
const runs = await json<Array<{ id: string; status: string }>>(
await request.get(
`/api/companies/${companyId}/heartbeat-runs?agentId=${agentId}&limit=10`,
),
);
terminalRun =
runs.find((run) => !["queued", "running"].includes(run.status)) ??
null;
return terminalRun?.status ?? null;
},
{ timeout: 45_000 },
)
.toBe("succeeded");
if (!terminalRun)
throw new Error("Agent run completed without a run receipt");
return terminalRun;
}
test("store setup and task connection intent share one fake provider through continuation", async ({
page,
request,
}) => {
test.setTimeout(180_000);
const provider = await startFakeProvider();
try {
const seed = await newCompany(request);
const holder = await createAgent(
request,
seed.companyId,
"Existing access holder",
);
// Entry point one: connect and test the provider through the Connections store.
await page.goto(`/${seed.prefix}/apps`);
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({
timeout: 30_000,
});
const customConnector = page
.getByRole("list", { name: "Connector list" })
.getByRole("listitem")
.filter({ hasText: "Connect your own tool" });
await customConnector.getByRole("button", { name: "Connect", exact: true }).click();
await customConnector.getByRole("button", { name: "Connect your own MCP server" }).click();
await page
.getByPlaceholder("https://example.com/actions")
.fill(provider.url);
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Save and continue" }).click();
await page.getByRole("button", { name: /Check link/i }).click();
// A no-auth read-only provider can complete the access/install defaults in
// one commit. Other methods exercise the same intermediate steps in the
// shared-flow component suite.
await expect(page.getByRole("heading", { name: /is ready/i })).toBeVisible({
timeout: 30_000,
});
const connections = await json<{
connections: Array<{ id: string; name: string; config: Json }>;
}>(await request.get(`/api/companies/${seed.companyId}/tools/connections`));
expect(connections.connections).toHaveLength(1);
const connection = connections.connections[0]!;
const connectionId = connection.id;
await json(
await request.patch(`/api/tool-connections/${connectionId}`, {
data: {
config: {
...connection.config,
url: provider.url,
sourceTemplateKey: "notion",
},
},
}),
);
await json(
await request.put(`/api/tool-connections/${connectionId}/installs`, {
data: {
installs: [{ targetType: "agent", targetId: holder.id }],
},
}),
);
await page.goto(`/${seed.prefix}/apps/${connectionId}/permissions`);
const actionRow = page.locator("[data-action-id]").filter({ hasText: "List fixture pages" });
await actionRow.getByRole("button", { name: "Test", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Test List fixture pages" }),
).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: "Run", exact: true }).click();
await expect(page.getByText("Fixture page inventory")).toBeVisible({
timeout: 30_000,
});
// Entry point two: a scripted agent requests Notion, then the same shared
// provider is reused from the task dialog and appears in the fresh run.
const scout = await createAgent(
request,
seed.companyId,
"Connection requester",
);
await json(
await request.patch(`/api/agents/${scout.id}`, {
data: {
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: [
"--input-type=module",
"-e",
connectionAwareScript(connectionId),
],
},
replaceAdapterConfig: true,
},
}),
);
const issue = await json<{ id: string; identifier: string }>(
await request.post(`/api/companies/${seed.companyId}/issues`, {
data: {
title: "Read our Notion pages",
status: "in_progress",
assigneeAgentId: scout.id,
},
}),
);
// Assigning an in-progress task is the production wake path. Waiting for
// that run avoids creating a second artificial request from an explicit
// heartbeat invocation.
const firstRun = await waitForAgentRun(request, seed.companyId, scout.id);
const taskUrl = `/${seed.prefix}/issues/${issue.identifier}`;
await page.goto(taskUrl);
await expect(
page.getByText("Connection requester needs Notion"),
).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: "Connect / Use existing" }).click();
await expect(
page.getByRole("heading", { name: "Use an existing connection" }),
).toBeVisible();
await page
.getByRole("button", { name: new RegExp(escapeRegExp(connection.name)) })
.click();
await expect(page.getByText("Notion connected")).toBeVisible({
timeout: 30_000,
});
await expect(page).toHaveURL(new RegExp(`${taskUrl}$`));
await expect(
page
.getByTestId("connection-intent-focus-target")
.filter({ hasText: "Notion connected" }),
).toBeFocused();
expect(await page.locator("body").innerText()).not.toMatch(
/\/authorize\?|authorizationUrl/,
);
await expect
.poll(
async () => {
const runs = await json<Array<{ id: string; status: string }>>(
await request.get(
`/api/companies/${seed.companyId}/heartbeat-runs?agentId=${scout.id}&limit=10`,
),
);
return runs.find((run) => run.id !== firstRun.id)?.status ?? null;
},
{ timeout: 45_000 },
)
.toBe("succeeded");
await expect
.poll(() =>
provider.captures.some(
(capture) =>
capture.method === "tools/call" &&
capture.toolName === "notion:list_pages",
),
)
.toBe(true);
const interactions = await json<Array<{ kind: string; status: string }>>(
await request.get(`/api/issues/${issue.id}/interactions`),
);
const connectionIntents = interactions.filter(
(interaction) => interaction.kind === "connection_intent",
);
expect(
connectionIntents.filter(
(interaction) => interaction.status === "accepted",
),
).toHaveLength(1);
expect(
connectionIntents.filter(
(interaction) => interaction.status === "pending",
),
).toHaveLength(0);
expect(
connectionIntents.every((interaction) =>
["accepted", "expired"].includes(interaction.status),
),
).toBe(true);
expect(holder.id).not.toBe(scout.id);
} finally {
await provider.close();
}
});