diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 20cb90a18e..8c8c6ca3f5 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,15 +1,19 @@ -import { test, expect, mock, beforeEach } from "bun:test" +import { expect, mock, beforeEach } from "bun:test" import { EventEmitter } from "events" -import { Effect } from "effect" +import { Deferred, Effect, Layer, Option } from "effect" +import type { Duration } from "effect" +import { testEffect } from "../lib/effect" import type { MCP as MCPNS } from "../../src/mcp/index" // Track open() calls and control failure behavior let openShouldFail = false let openCalledWith: string | undefined +let openDeferred: Deferred.Deferred | undefined void mock.module("open", () => ({ default: async (url: string) => { openCalledWith = url + if (openDeferred) Effect.runSync(Deferred.succeed(openDeferred, url).pipe(Effect.ignore)) // Return a mock subprocess that emits an error if openShouldFail is true const subprocess = new EventEmitter() @@ -97,173 +101,134 @@ void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ beforeEach(() => { openShouldFail = false openCalledWith = undefined + openDeferred = undefined transportCalls.length = 0 }) // Import modules after mocking const { MCP } = await import("../../src/mcp/index") -const { AppRuntime } = await import("../../src/effect/app-runtime") const { Bus } = await import("../../src/bus") +const { Config } = await import("../../src/config/config") +const { McpAuth } = await import("../../src/mcp/auth") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") -const { Instance } = await import("../../src/project/instance") -const { WithInstance } = await import("../../src/project/with-instance") -const { tmpdir } = await import("../fixture/fixture") +const { AppFileSystem } = await import("@opencode-ai/core/filesystem") +const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") +const mcpTest = testEffect( + MCP.layer.pipe( + Layer.provide(McpAuth.defaultLayer), + Layer.provideMerge(Bus.layer), + Layer.provide(Config.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + ), +) const service = MCP.Service as unknown as Effect.Effect -test("BrowserOpenFailed event is published when open() throws", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - `${dir}/opencode.json`, - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "test-oauth-server": { - type: "remote", - url: "https://example.com/mcp", - }, - }, - }), - ) +const config = (name: string) => ({ + mcp: { + [name]: { + type: "remote" as const, + url: "https://example.com/mcp", }, + }, +}) + +const withCallbackStop = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) + +const awaitWithTimeout = ( + self: Effect.Effect, + message: string, + duration: Duration.Input = "5 seconds", +) => + self.pipe( + Effect.timeoutOrElse({ + duration, + orElse: () => Effect.fail(new Error(message)), + }), + ) + +const trackBrowserOpen = Effect.gen(function* () { + const opened = yield* Deferred.make() + openDeferred = opened + yield* Effect.addFinalizer(() => Effect.sync(() => (openDeferred = undefined))) + return opened +}) + +const trackBrowserOpenFailed = Effect.gen(function* () { + const bus = yield* Bus.Service + const event = yield* Deferred.make<{ mcpName: string; url: string }>() + const unsubscribe = yield* bus.subscribeCallback(MCP.BrowserOpenFailed, (evt) => { + Effect.runSync(Deferred.succeed(event, evt.properties).pipe(Effect.ignore)) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) + return event +}) + +const authenticateScoped = (name: string) => + Effect.gen(function* () { + const mcp = yield* service + yield* mcp.authenticate(name).pipe(Effect.ignore, Effect.catchCause(() => Effect.void), Effect.forkScoped) }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { +mcpTest.instance( + "BrowserOpenFailed event is published when open() throws", + () => + Effect.gen(function* () { + yield* withCallbackStop openShouldFail = true - const events: Array<{ mcpName: string; url: string }> = [] - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - events.push(evt.properties) - }) + const event = yield* trackBrowserOpenFailed + yield* authenticateScoped("test-oauth-server") - // Run authenticate with a timeout to avoid waiting forever for the callback - // Attach a handler immediately so callback shutdown rejections - // don't show up as unhandled between tests. - const authPromise = AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* service - return yield* mcp.authenticate("test-oauth-server") - }), - ).catch(() => undefined) - - // Config.get() can be slow in tests, so give it plenty of time. - await new Promise((resolve) => setTimeout(resolve, 2_000)) - - // Stop the callback server and cancel any pending auth - await McpOAuthCallback.stop() - - await authPromise - - unsubscribe() - - // Verify the BrowserOpenFailed event was published - expect(events.length).toBe(1) - expect(events[0].mcpName).toBe("test-oauth-server") - expect(events[0].url).toContain("https://") - }, - }) -}) - -test("BrowserOpenFailed event is NOT published when open() succeeds", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - `${dir}/opencode.json`, - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "test-oauth-server-2": { - type: "remote", - url: "https://example.com/mcp", - }, - }, - }), + const failure = yield* awaitWithTimeout( + Deferred.await(event), + "Timed out waiting for BrowserOpenFailed event", ) - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { + expect(failure.mcpName).toBe("test-oauth-server") + expect(failure.url).toContain("https://") + }), + { config: config("test-oauth-server") }, +) + +mcpTest.instance( + "BrowserOpenFailed event is NOT published when open() succeeds", + () => + Effect.gen(function* () { + yield* withCallbackStop openShouldFail = false - const events: Array<{ mcpName: string; url: string }> = [] - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - events.push(evt.properties) - }) + const opened = yield* trackBrowserOpen + const event = yield* trackBrowserOpenFailed + yield* authenticateScoped("test-oauth-server-2") - // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* service - return yield* mcp.authenticate("test-oauth-server-2") - }), - ).catch(() => undefined) + yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()") + const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) - // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window. - await new Promise((resolve) => setTimeout(resolve, 2_000)) - - // Stop the callback server and cancel any pending auth - await McpOAuthCallback.stop() - - await authPromise - - unsubscribe() - - // Verify NO BrowserOpenFailed event was published - expect(events.length).toBe(0) - // Verify open() was still called + expect(failure).toEqual(Option.none()) expect(openCalledWith).toBeDefined() - }, - }) -}) + }), + { config: config("test-oauth-server-2") }, +) -test("open() is called with the authorization URL", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - `${dir}/opencode.json`, - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "test-oauth-server-3": { - type: "remote", - url: "https://example.com/mcp", - }, - }, - }), - ) - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { +mcpTest.instance( + "open() is called with the authorization URL", + () => + Effect.gen(function* () { + yield* withCallbackStop openShouldFail = false openCalledWith = undefined - // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* service - return yield* mcp.authenticate("test-oauth-server-3") - }), - ).catch(() => undefined) + const opened = yield* trackBrowserOpen + const event = yield* trackBrowserOpenFailed + yield* authenticateScoped("test-oauth-server-3") - // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window. - await new Promise((resolve) => setTimeout(resolve, 2_000)) + const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()") + const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) - // Stop the callback server and cancel any pending auth - await McpOAuthCallback.stop() - - await authPromise - - // Verify open was called with a URL - expect(openCalledWith).toBeDefined() - expect(typeof openCalledWith).toBe("string") - expect(openCalledWith!).toContain("https://") - }, - }) -}) + expect(failure).toEqual(Option.none()) + expect(typeof url).toBe("string") + expect(url).toContain("https://") + }), + { config: config("test-oauth-server-3") }, +)