| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261 |
- import { test, expect, mock, beforeEach } from "bun:test"
- import { EventEmitter } from "events"
- // Track open() calls and control failure behavior
- let openShouldFail = false
- let openCalledWith: string | undefined
- mock.module("open", () => ({
- default: async (url: string) => {
- openCalledWith = url
- // Return a mock subprocess that emits an error if openShouldFail is true
- const subprocess = new EventEmitter()
- if (openShouldFail) {
- // Emit error asynchronously like a real subprocess would
- setTimeout(() => {
- subprocess.emit("error", new Error("spawn xdg-open ENOENT"))
- }, 10)
- }
- return subprocess
- },
- }))
- // Mock UnauthorizedError
- class MockUnauthorizedError extends Error {
- constructor() {
- super("Unauthorized")
- this.name = "UnauthorizedError"
- }
- }
- // Track what options were passed to each transport constructor
- const transportCalls: Array<{
- type: "streamable" | "sse"
- url: string
- options: { authProvider?: unknown }
- }> = []
- // Mock the transport constructors
- mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
- StreamableHTTPClientTransport: class MockStreamableHTTP {
- url: string
- authProvider: { redirectToAuthorization?: (url: URL) => Promise<void> } | undefined
- constructor(url: URL, options?: { authProvider?: { redirectToAuthorization?: (url: URL) => Promise<void> } }) {
- this.url = url.toString()
- this.authProvider = options?.authProvider
- transportCalls.push({
- type: "streamable",
- url: url.toString(),
- options: options ?? {},
- })
- }
- async start() {
- // Simulate OAuth redirect by calling the authProvider's redirectToAuthorization
- if (this.authProvider?.redirectToAuthorization) {
- await this.authProvider.redirectToAuthorization(new URL("https://auth.example.com/authorize?client_id=test"))
- }
- throw new MockUnauthorizedError()
- }
- async finishAuth(_code: string) {
- // Mock successful auth completion
- }
- },
- }))
- mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
- SSEClientTransport: class MockSSE {
- constructor(url: URL) {
- transportCalls.push({
- type: "sse",
- url: url.toString(),
- options: {},
- })
- }
- async start() {
- throw new Error("Mock SSE transport cannot connect")
- }
- },
- }))
- // Mock the MCP SDK Client to trigger OAuth flow
- mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
- Client: class MockClient {
- async connect(transport: { start: () => Promise<void> }) {
- await transport.start()
- }
- },
- }))
- // Mock UnauthorizedError in the auth module
- mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
- UnauthorizedError: MockUnauthorizedError,
- }))
- beforeEach(() => {
- openShouldFail = false
- openCalledWith = undefined
- transportCalls.length = 0
- })
- // Import modules after mocking
- const { MCP } = await import("../../src/mcp/index")
- const { Bus } = await import("../../src/bus")
- const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
- const { Instance } = await import("../../src/project/instance")
- const { tmpdir } = await import("../fixture/fixture")
- 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",
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- openShouldFail = true
- const events: Array<{ mcpName: string; url: string }> = []
- const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
- events.push(evt.properties)
- })
- // Run authenticate with a timeout to avoid waiting forever for the callback
- const authPromise = MCP.authenticate("test-oauth-server")
- // Wait for the browser open attempt (error fires at 10ms, but we wait for event to be published)
- await new Promise((resolve) => setTimeout(resolve, 200))
- // Stop the callback server and cancel any pending auth
- await McpOAuthCallback.stop()
- // Wait for authenticate to reject (due to server stopping)
- try {
- await authPromise
- } catch {
- // Expected to fail
- }
- 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",
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- openShouldFail = false
- const events: Array<{ mcpName: string; url: string }> = []
- const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
- events.push(evt.properties)
- })
- // Run authenticate with a timeout to avoid waiting forever for the callback
- const authPromise = MCP.authenticate("test-oauth-server-2")
- // Wait for the browser open attempt and the 500ms error detection timeout
- await new Promise((resolve) => setTimeout(resolve, 700))
- // Stop the callback server and cancel any pending auth
- await McpOAuthCallback.stop()
- // Wait for authenticate to reject (due to server stopping)
- try {
- await authPromise
- } catch {
- // Expected to fail
- }
- unsubscribe()
- // Verify NO BrowserOpenFailed event was published
- expect(events.length).toBe(0)
- // Verify open() was still called
- expect(openCalledWith).toBeDefined()
- },
- })
- })
- 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 Instance.provide({
- directory: tmp.path,
- fn: async () => {
- openShouldFail = false
- openCalledWith = undefined
- // Run authenticate with a timeout to avoid waiting forever for the callback
- const authPromise = MCP.authenticate("test-oauth-server-3")
- // Wait for the browser open attempt and the 500ms error detection timeout
- await new Promise((resolve) => setTimeout(resolve, 700))
- // Stop the callback server and cancel any pending auth
- await McpOAuthCallback.stop()
- // Wait for authenticate to reject (due to server stopping)
- try {
- await authPromise
- } catch {
- // Expected to fail
- }
- // Verify open was called with a URL
- expect(openCalledWith).toBeDefined()
- expect(typeof openCalledWith).toBe("string")
- expect(openCalledWith!).toContain("https://")
- },
- })
- })
|