History

Add browser resume page e2e coverage

Rhys Sullivan committed 4 months ago

Commit 6e7a4421 parent c2c78d42 files · +100 −0Browse files

Unified · Split

2 files
  1. e2e/cloud/mcp-browser-resume-page.test.ts +101 −1
  2. e2e/recordings/mcp-browser-resume-page.mp4 +0 −0
e2e/cloud/mcp-browser-resume-page.test.ts+101−1
@@ -15,13 +15,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 14 unmodified lines
1515 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1616
1717 import { scenario } from "../src/scenario";
18−import { Api, Mcp, Target } from "../src/services";
18+import { Api, Browser, Mcp, Target } from "../src/services";
1919 import { parseBrowserApproval } from "../src/surfaces/mcp";
2020 import type { Identity } from "../src/target";
2121
2222 const coreApi = composePluginApi([] as const);
2323
2424 const GATE_TOOL = "executor.coreTools.policies.list";
25+const UNAVAILABLE_COPY = "This paused execution is no longer available";
2526
2627 const GATED_CODE = `
2728 const result = await tools.executor.coreTools.policies.list({});
@@ -63,6 +64,11 @@ const openBrowserApprovalSession = async (mcpUrl: string, bearer: string): Promi · 35 unmodified lines
6364 const closeQuietly = (connected: Connected): Effect.Effect<void> =>
6465 Effect.promise(() => connected.client.close().catch(() => undefined));
6566
67+const pathWithSearch = (url: string): string => {
68+ const parsed = new URL(url);
69+ return `${parsed.pathname}${parsed.search}`;
70+};
71+
6672 const authenticatedFetch = (
6773 identity: Identity,
6874 input: URL,
@@ -167,3 +173,97 @@ scenario( · 98 unmodified lines
167173 );
168174 }),
169175 );
176+
177+scenario(
178+ "MCP approval · browser resume page approves a paused execution through the UI",
179+ { timeout: 180_000 },
180+ Effect.gen(function* () {
181+ const target = yield* Target;
182+ const { client: apiClient } = yield* Api;
183+ const browser = yield* Browser;
184+ const mcp = yield* Mcp;
185+ const identity = yield* target.newIdentity();
186+ const bearer = yield* mcp.mintBearer(emailOf(identity));
187+ const api = yield* apiClient(coreApi, identity);
188+
189+ const policy = yield* api.policies.create({
190+ payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" },
191+ });
192+
193+ yield* Effect.gen(function* () {
194+ const session = yield* Effect.promise(() =>
195+ openBrowserApprovalSession(target.mcpUrl, bearer),
196+ );
197+ yield* Effect.gen(function* () {
198+ const paused = yield* Effect.promise(() =>
199+ session.client.callTool({ name: "execute", arguments: { code: GATED_CODE } }),
200+ );
201+ const approval = parseBrowserApproval({
202+ raw: paused,
203+ text: textOf(paused),
204+ ok: paused.isError !== true,
205+ });
206+
207+ const approvalUrl = new URL(approval.approvalUrl);
208+ const mcpSessionId = approvalUrl.searchParams.get("mcp_session_id");
209+ expect(
210+ mcpSessionId,
211+ "approval URL carries the MCP session id that the browser page will query",
212+ ).toEqual(expect.any(String));
213+ expect(mcpSessionId, "approval URL points at the session that paused").toBe(
214+ session.transport.sessionId,
215+ );
216+
217+ const [resumed] = yield* Effect.all(
218+ [
219+ Effect.promise(() =>
220+ session.client.callTool({
221+ name: "resume",
222+ arguments: { executionId: approval.executionId },
223+ }),
224+ ),
225+ browser.session(identity, async ({ page, step }) => {
226+ await step("Open the paused execution approval page", async () => {
227+ await page.goto(pathWithSearch(approval.approvalUrl), { waitUntil: "networkidle" });
228+ await page.getByText("User approval required").waitFor();
229+ });
230+
231+ await step("Review the paused tool call details", async () => {
232+ await page.getByText("Pending request").waitFor();
233+ await page.getByText(/Approve executor\.coreTools\.policies\.list\?/).waitFor();
234+
235+ const approve = page.getByRole("button", { name: "Approve" });
236+ await approve.waitFor();
237+ expect(
238+ await approve.isEnabled(),
239+ "the approve control is enabled for the paused execution",
240+ ).toBe(true);
241+ expect(
242+ await page.getByText(UNAVAILABLE_COPY).count(),
243+ "the resume page does not show the expired-session failure copy",
244+ ).toBe(0);
245+ });
246+
247+ await step("Approve the paused tool call", async () => {
248+ await page.getByRole("button", { name: "Approve" }).click();
249+ await page.getByText("Approve sent").waitFor();
250+ });
251+ }),
252+ ],
253+ { concurrency: "unbounded" },
254+ );
255+
256+ expect(resumed.isError, "browser-mode resume completed after the UI approval").not.toBe(
257+ true,
258+ );
259+ expect(textOf(resumed), "the gated tool completed after approval").toContain(policy.id);
260+ }).pipe(Effect.ensuring(closeQuietly(session)));
261+ }).pipe(
262+ Effect.ensuring(
263+ api.policies
264+ .remove({ params: { policyId: policy.id }, payload: { owner: "org" } })
265+ .pipe(Effect.ignore),
266+ ),
267+ );
268+ }),
269+);
e2e/recordings/mcp-browser-resume-page.mp4+0−0

Binary file not shown.