import { expect, test, type APIRequestContext } from "@playwright/test"; import { createServer, type Server } from "node:http"; import { listenOnFetchAllowedPort } from "./fetch-allowed-port"; type Json = Record; type Seed = { companyId: string; prefix: string }; type Agent = { id: string; name: string }; async function json( response: Awaited>, ): Promise { 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 { 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 { return await json( 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 ?? ""), 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((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>( 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>( 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>( 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(); } });