|
| 1 | +// Selfhost browser coverage for the transparent DCR connect when OAuth |
| 2 | +// discovery is slow, the shape behind the report: "When I hit connect, it loads |
| 3 | +// for a second, but then theres no user feedback and nothing happens." |
| 4 | +// |
| 5 | +// One click runs probe -> register -> start. The first two are network round |
| 6 | +// trips, and `window.open` needs transient user activation, which a real |
| 7 | +// browser expires a few seconds after the click. The shipped code opened the |
| 8 | +// sign-in window AFTER both round trips, so once the API was slow the browser |
| 9 | +// refused it and the connect died silently. The window is now claimed on the |
| 10 | +// click and navigated later, so discovery latency no longer decides whether the |
| 11 | +// user can connect. |
| 12 | +// |
| 13 | +// What this scenario guards: the whole slow path still works end to end, with |
| 14 | +// the reservation threaded from the click through registration to a window that |
| 15 | +// really lands on the discovered authorization server. |
| 16 | +// |
| 17 | +// What it CANNOT guard, and why: Playwright drives Chromium in automation mode, |
| 18 | +// which never enforces the activation rule for `window.open` (verified headed |
| 19 | +// and headless, and with `--disable-popup-blocking` removed via |
| 20 | +// ignoreDefaultArgs). So a browser here opens the window no matter how stale |
| 21 | +// the click is, and this scenario passes against the pre-fix ordering too. The |
| 22 | +// ordering itself is pinned by unit tests over `runDcrConnect` in |
| 23 | +// packages/react/src/components/add-account-modal.test.ts, which do fail when |
| 24 | +// the reservation moves back after the round trips. See LEARNINGS.md. |
| 25 | +import { randomBytes } from "node:crypto"; |
| 26 | + |
| 27 | +import { expect } from "@effect/vitest"; |
| 28 | +import { Effect } from "effect"; |
| 29 | +import { composePluginApi } from "@executor-js/api/server"; |
| 30 | +import { deriveMcpNamespace } from "@executor-js/plugin-mcp"; |
| 31 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 32 | +import { makeGreetingMcpServer, serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing"; |
| 33 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; |
| 34 | +import { OAuthTestServer } from "@executor-js/sdk/testing"; |
| 35 | + |
| 36 | +import { scenario } from "../src/scenario"; |
| 37 | +import { Api, Browser, Target } from "../src/services"; |
| 38 | + |
| 39 | +const api = composePluginApi([mcpHttpPlugin()] as const); |
| 40 | + |
| 41 | +// Comfortably past Chromium's ~5s transient user activation once both land on |
| 42 | +// the same click, and well under the step timeouts below. |
| 43 | +const STALL_MS = 3_500; |
| 44 | + |
| 45 | +const isDiscoveryCall = (url: string): boolean => |
| 46 | + url.includes("/api/oauth/probe") || url.includes("/api/oauth/clients/register-dynamic"); |
| 47 | + |
| 48 | +scenario( |
| 49 | + "MCP OAuth · a slow discovery round trip still opens the sign-in window", |
| 50 | + { timeout: 240_000 }, |
| 51 | + Effect.scoped( |
| 52 | + Effect.gen(function* () { |
| 53 | + const target = yield* Target; |
| 54 | + const browser = yield* Browser; |
| 55 | + const { client: makeApiClient } = yield* Api; |
| 56 | + const oauth = yield* OAuthTestServer; |
| 57 | + const server = yield* serveMcpServerWithOAuth( |
| 58 | + () => makeGreetingMcpServer({ name: "slow-connect-mcp" }), |
| 59 | + { path: "/mcp" }, |
| 60 | + ); |
| 61 | + const identity = yield* target.newIdentity(); |
| 62 | + const client = yield* makeApiClient(api, identity); |
| 63 | + const displayName = `Slow MCP ${randomBytes(3).toString("hex")}`; |
| 64 | + const slug = IntegrationSlug.make(deriveMcpNamespace({ name: displayName })); |
| 65 | + |
| 66 | + yield* Effect.gen(function* () { |
| 67 | + yield* browser.session(identity, async ({ page, step }) => { |
| 68 | + await step("Add an OAuth-protected MCP integration", async () => { |
| 69 | + const addUrl = new URL("/integrations/add/mcp", target.baseUrl); |
| 70 | + addUrl.searchParams.set("url", server.endpoint); |
| 71 | + await page.goto(addUrl.toString(), { waitUntil: "networkidle" }); |
| 72 | + await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); |
| 73 | + await page.getByPlaceholder("e.g. Linear").fill(displayName); |
| 74 | + await page.getByRole("button", { name: "Add integration" }).click(); |
| 75 | + await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 }); |
| 76 | + await page.getByText("Connections").first().waitFor(); |
| 77 | + }); |
| 78 | + |
| 79 | + await step("Make OAuth discovery slow, the way a degraded API is", async () => { |
| 80 | + // Delay the responses rather than the requests, so the app sees a |
| 81 | + // genuinely slow API and not a stalled network stack. |
| 82 | + await page.route( |
| 83 | + (url) => isDiscoveryCall(url.href), |
| 84 | + async (route) => { |
| 85 | + await new Promise((resolve) => setTimeout(resolve, STALL_MS)); |
| 86 | + await route.continue(); |
| 87 | + }, |
| 88 | + ); |
| 89 | + }); |
| 90 | + |
| 91 | + await step("Connect, and wait out the slow discovery", async () => { |
| 92 | + await page.getByRole("button", { name: "Add connection" }).first().click(); |
| 93 | + await page.getByRole("heading", { name: /Add connection/ }).waitFor(); |
| 94 | + await page.getByRole("tab", { name: "OAuth" }).waitFor(); |
| 95 | + |
| 96 | + const popupPromise = page.waitForEvent("popup", { timeout: 60_000 }); |
| 97 | + await page.getByRole("button", { name: "Connect", exact: true }).click(); |
| 98 | + |
| 99 | + // In a real browser the two stalls outlast the click's user |
| 100 | + // activation, so this popup only exists because it was reserved on |
| 101 | + // the click. Automation-mode Chromium would open it either way; |
| 102 | + // the ordering is pinned by the unit tests named above. |
| 103 | + const popup = await popupPromise; |
| 104 | + await popup.waitForURL((url) => url.origin === new URL(oauth.issuerUrl).origin, { |
| 105 | + timeout: 60_000, |
| 106 | + }); |
| 107 | + await popup.waitForLoadState("domcontentloaded", { timeout: 30_000 }); |
| 108 | + expect( |
| 109 | + new URL(popup.url()).origin, |
| 110 | + "the reserved window reached the discovered authorization host", |
| 111 | + ).toBe(new URL(oauth.authorizationEndpoint).origin); |
| 112 | + await popup.close(); |
| 113 | + }); |
| 114 | + }); |
| 115 | + |
| 116 | + const oauthRequests = yield* oauth.requests; |
| 117 | + expect( |
| 118 | + oauthRequests.some( |
| 119 | + (request) => request.method === "POST" && request.path === "/register", |
| 120 | + ), |
| 121 | + "the slow connect still dynamically registered its OAuth client", |
| 122 | + ).toBe(true); |
| 123 | + expect( |
| 124 | + oauthRequests.some( |
| 125 | + (request) => request.method === "GET" && request.path === "/authorize", |
| 126 | + ), |
| 127 | + "the slow connect still reached the authorize endpoint", |
| 128 | + ).toBe(true); |
| 129 | + }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore))); |
| 130 | + }), |
| 131 | + ).pipe(Effect.provide(OAuthTestServer.layer())), |
| 132 | +); |
0 commit comments