Browse Source

test: cover resync coalescing, onclose, and error path in mcp-client

Addresses CI coverage gap: exercises the latest-wins resync coalescing
(pendingResync branch), the client.onclose callback, and ensures index.ts
is loaded without module mocks for stable v8 coverage across environments.
lintianle 3 months ago
parent
commit
2efe8ad418

+ 51 - 0
packages/mcp/mcp-client/tests/apply.spec.ts

@@ -22,6 +22,7 @@ class MockClient {
   listTools = mockListTools
   callTool = mockCallTool
   setNotificationHandler = mockSetNotificationHandler
+  onclose: (() => void) | null = null
 }
 
 vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
@@ -176,4 +177,54 @@ describe('apply (plugin lifecycle)', () => {
     expect(mockConnect).toHaveBeenCalled()
     expect(ctx.tools.get('remote')).toBeDefined()
   })
+
+  it('coalesces overlapping resync notifications (latest-wins)', async () => {
+    apply(ctx, stdioConfig)
+    await new Promise(r => setTimeout(r, 50))
+
+    // Initial sync is done; notification handler is registered.
+    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
+
+    // Make the NEXT listTools call slow so we can trigger a second notification.
+    let resolveBlocked!: (v: unknown) => void
+    mockListTools.mockReturnValueOnce(new Promise((r) => { resolveBlocked = r }))
+
+    // Fire first notification — starts a resync that blocks on listTools.
+    const firstResync = handler()
+
+    // Fire second notification while the first is in-flight — should coalesce.
+    const secondResync = handler()
+
+    // Resolve the blocked listTools call.
+    resolveBlocked({ tools: [{ name: 'mid', inputSchema: { type: 'object' } }], nextCursor: undefined })
+
+    // Set up the response for the deferred resync that fires after the first completes.
+    mockListTools.mockResolvedValueOnce({
+      tools: [{ name: 'final', inputSchema: { type: 'object' } }],
+      nextCursor: undefined,
+    })
+
+    await firstResync
+    await secondResync
+    await new Promise(r => setTimeout(r, 50))
+
+    // The deferred resync should have run with the latest tool list.
+    expect(ctx.tools.get('final')).toBeDefined()
+  })
+
+  it('unregisters tools when the server connection closes (onclose)', async () => {
+    apply(ctx, stdioConfig)
+    await new Promise(r => setTimeout(r, 50))
+
+    expect(ctx.tools.get('remote')).toBeDefined()
+
+    // Simulate the MCP client's onclose firing (server crashed or closed).
+    // The apply() sets `client.onclose = () => {...}` on the mock instance.
+    // mockConnect receives `this` as the client instance.
+    const clientInstance = mockConnect.mock.contexts[0] as MockClient
+    expect(clientInstance.onclose).toBeTypeOf('function')
+    clientInstance.onclose!()
+
+    expect(ctx.tools.get('remote')).toBeUndefined()
+  })
 })

+ 35 - 1
packages/mcp/mcp-client/tests/mcp-client.spec.ts

@@ -5,7 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import { syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
 import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
-import type { Config } from '@deepseek-ai/dsh-mcp-client'
+import { apply, name, inject, Config } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
 
 // ---- Mock MCP Client ----
 
@@ -516,3 +516,37 @@ describe('tool execution — non-object args fallback', () => {
   })
 })
 
+describe('plugin module exports', () => {
+  it('exports name, inject, and Config schema', () => {
+    expect(name).toBe('mcp-client')
+    expect(inject).toEqual(['tools'])
+    expect(Config).toBeDefined()
+  })
+})
+
+describe('apply (error path, no mocks)', () => {
+  it('gracefully catches when the MCP server is unreachable', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+
+    // Call apply with a command that will fail to spawn/connect.
+    // The .catch() inside apply logs the error and registers no tools.
+    apply(ctx, {
+      transport: 'stdio',
+      command: '___nonexistent_binary_that_will_fail___',
+      args: [],
+      env: {},
+      cwd: '',
+      toolPrefix: '',
+      toolCallTimeoutMs: 1000,
+    })
+
+    // Give the async connect + catch chain time to settle.
+    await new Promise(r => setTimeout(r, 200))
+
+    // No tools should be registered since connect failed.
+    expect(ctx.tools.get('anything')).toBeUndefined()
+  })
+})
+