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
15import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";15import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
16 16
17import { scenario } from "../src/scenario";17import { scenario } from "../src/scenario";
18import { Api, Mcp, Target } from "../src/services";18import { Api, Browser, Mcp, Target } from "../src/services";
19import { parseBrowserApproval } from "../src/surfaces/mcp";19import { parseBrowserApproval } from "../src/surfaces/mcp";
20import type { Identity } from "../src/target";20import type { Identity } from "../src/target";
21 21
22const coreApi = composePluginApi([] as const);22const coreApi = composePluginApi([] as const);
23 23
24const GATE_TOOL = "executor.coreTools.policies.list";24const GATE_TOOL = "executor.coreTools.policies.list";
25const UNAVAILABLE_COPY = "This paused execution is no longer available";
25 26
26const GATED_CODE = `27const GATED_CODE = `
27const result = await tools.executor.coreTools.policies.list({});28const result = await tools.executor.coreTools.policies.list({});
@@ -63,6 +64,11 @@ const openBrowserApprovalSession = async (mcpUrl: string, bearer: string): Promi · 35 unmodified lines
63const closeQuietly = (connected: Connected): Effect.Effect<void> =>64const closeQuietly = (connected: Connected): Effect.Effect<void> =>
64 Effect.promise(() => connected.client.close().catch(() => undefined));65 Effect.promise(() => connected.client.close().catch(() => undefined));
65 66
67const pathWithSearch = (url: string): string => {
68 const parsed = new URL(url);
69 return `${parsed.pathname}${parsed.search}`;
70};
71
66const authenticatedFetch = (72const authenticatedFetch = (
67 identity: Identity,73 identity: Identity,
68 input: URL,74 input: URL,
@@ -167,3 +173,97 @@ scenario( · 98 unmodified lines
167 );173 );
168 }),174 }),
169);175);
176
177scenario(
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.