Skip to content

Commit 6e7a442

Browse files
committed
Add browser resume page e2e coverage
1 parent c2c78d4 commit 6e7a442

2 files changed

Lines changed: 101 additions & 1 deletion

File tree

e2e/cloud/mcp-browser-resume-page.test.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
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
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(
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+
);
108 KB
Binary file not shown.

0 commit comments

Comments
 (0)