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.
This commit is contained in:
@@ -22,6 +22,7 @@ class MockClient {
|
|||||||
listTools = mockListTools
|
listTools = mockListTools
|
||||||
callTool = mockCallTool
|
callTool = mockCallTool
|
||||||
setNotificationHandler = mockSetNotificationHandler
|
setNotificationHandler = mockSetNotificationHandler
|
||||||
|
onclose: (() => void) | null = null
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||||
@@ -176,4 +177,54 @@ describe('apply (plugin lifecycle)', () => {
|
|||||||
expect(mockConnect).toHaveBeenCalled()
|
expect(mockConnect).toHaveBeenCalled()
|
||||||
expect(ctx.tools.get('remote')).toBeDefined()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||||
import { syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
|
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 { 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 ----
|
// ---- 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user