Skip to content

Commit a48d492

Browse files
committed
feat: implement IPC handlers for registry and stacks management, including search, install, export, and import functionalities with corresponding unit tests
1 parent b3755f2 commit a48d492

23 files changed

Lines changed: 2268 additions & 9 deletions

TASK.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,11 @@ affected unit tests pass, and both `pnpm build` and `pnpm lint` succeed.
6262
- [x] 39. Git sync: GitHub OAuth quick setup flow
6363
- [x] 40. Git sync: Advanced manual setup (any Git provider)
6464
- [x] 41. Push/pull with conflict detection and merge
65-
- [ ] 42. Smithery registry API client
66-
- [ ] 43. Registry browser page
67-
- [ ] 44. One-click install from registry (Pro feature)
68-
- [ ] 45. Import/export stacks (servers + rules bundles)
69-
- [ ] 46. MCP server connection testing (spawn, initialize, verify)
65+
- [x] 42. Smithery registry API client
66+
- [x] 43. Registry browser page
67+
- [x] 44. One-click install from registry (Pro feature)
68+
- [x] 45. Import/export stacks (servers + rules bundles)
69+
- [x] 46. MCP server connection testing (spawn, initialize, verify)
7070

7171
## Phase 6 — Polish + Release
7272

eslint.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ export default [
121121
rules: {
122122
'@typescript-eslint/no-explicit-any': 'warn',
123123
'@typescript-eslint/no-non-null-assertion': 'off',
124+
// Vitest patterns (vi.mocked, mock.calls, etc.) routinely access methods
125+
// outside their original object context — this is intentional in tests.
126+
'@typescript-eslint/unbound-method': 'off',
124127
},
125128
},
126129
]
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @file src/main/ipc/__tests__/registry.ipc.test.ts
3+
*
4+
* @created 07.03.2026
5+
* @modified 07.03.2026
6+
*
7+
* @author Christian Blank <christianblank91@protonmail.com>
8+
* @copyright 2026
9+
*
10+
* @description Unit tests for registry IPC handlers. The Smithery client,
11+
* database repos, and feature gates are all mocked so tests run in isolation.
12+
*/
13+
14+
import { describe, it, expect, vi, beforeEach } from 'vitest'
15+
import { createTestDb } from '@main/db/__tests__/helpers'
16+
import { getDatabase } from '@main/db/connection'
17+
18+
// ─── Module Mocks ─────────────────────────────────────────────────────────────
19+
20+
vi.mock('electron', () => ({
21+
ipcMain: { handle: vi.fn() },
22+
}))
23+
24+
vi.mock('electron-log', () => ({
25+
default: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn() },
26+
}))
27+
28+
vi.mock('@main/db/connection', () => ({ getDatabase: vi.fn() }))
29+
30+
vi.mock('@main/licensing/feature-gates', () => ({
31+
checkGate: vi.fn().mockImplementation((key: string) => {
32+
if (key === 'registryInstall') return true
33+
if (key === 'maxServers') return Infinity
34+
return true
35+
}),
36+
}))
37+
38+
vi.mock('@main/registry/smithery.client', () => ({
39+
smitheryClient: {
40+
searchServers: vi.fn().mockResolvedValue([]),
41+
},
42+
}))
43+
44+
// ─── Helpers ──────────────────────────────────────────────────────────────────
45+
46+
import { ipcMain } from 'electron'
47+
import { checkGate } from '@main/licensing/feature-gates'
48+
import { smitheryClient } from '@main/registry/smithery.client'
49+
import { registerRegistryIpc } from '../registry.ipc'
50+
51+
type IpcHandler = (_event: unknown, ...args: unknown[]) => unknown
52+
53+
/** Returns the handler registered for a given channel. */
54+
const getHandler = (channel: string): IpcHandler => {
55+
const calls = vi.mocked(ipcMain.handle).mock.calls
56+
const call = calls.find(([ch]) => ch === channel)
57+
if (!call) throw new Error(`No handler registered for "${channel}"`)
58+
return call[1] as IpcHandler
59+
}
60+
61+
// ─── Tests ────────────────────────────────────────────────────────────────────
62+
63+
describe('registry IPC handlers', () => {
64+
beforeEach(() => {
65+
vi.clearAllMocks()
66+
const db = createTestDb()
67+
vi.mocked(getDatabase).mockReturnValue(db)
68+
vi.mocked(checkGate).mockImplementation((key: string) => {
69+
if (key === 'registryInstall') return true
70+
if (key === 'maxServers') return Infinity
71+
return true
72+
})
73+
registerRegistryIpc()
74+
})
75+
76+
describe('registry:search', () => {
77+
it('returns results from the Smithery client', async () => {
78+
const mockResults = [
79+
{
80+
id: '@a/b',
81+
displayName: 'B',
82+
description: '',
83+
source: 'smithery' as const,
84+
verified: false,
85+
remote: false,
86+
},
87+
]
88+
vi.mocked(smitheryClient.searchServers).mockResolvedValueOnce(mockResults)
89+
90+
const handler = getHandler('registry:search')
91+
const result = await handler(null, 'github')
92+
93+
expect(smitheryClient.searchServers).toHaveBeenCalledWith('github')
94+
expect(result).toEqual(mockResults)
95+
})
96+
97+
it('returns empty array when Smithery returns nothing', async () => {
98+
vi.mocked(smitheryClient.searchServers).mockResolvedValueOnce([])
99+
100+
const handler = getHandler('registry:search')
101+
const result = await handler(null, 'nothing')
102+
103+
expect(result).toEqual([])
104+
})
105+
})
106+
107+
describe('registry:install', () => {
108+
it('creates a server from the qualified name and returns it', async () => {
109+
const handler = getHandler('registry:install')
110+
const server = await handler(null, '@anthropic/github-mcp')
111+
112+
expect(server).toMatchObject({
113+
name: 'github-mcp',
114+
command: 'npx',
115+
args: ['-y', '@anthropic/github-mcp'],
116+
type: 'stdio',
117+
})
118+
})
119+
120+
it('throws when the registryInstall gate is false', async () => {
121+
vi.mocked(checkGate).mockImplementation((key: string) => {
122+
if (key === 'registryInstall') return false
123+
if (key === 'maxServers') return Infinity
124+
return true
125+
})
126+
127+
const handler = getHandler('registry:install')
128+
await expect(handler(null, '@some/server')).rejects.toThrow('Pro')
129+
})
130+
131+
it('throws when the server limit is reached', async () => {
132+
vi.mocked(checkGate).mockImplementation((key: string) => {
133+
if (key === 'registryInstall') return true
134+
if (key === 'maxServers') return 0
135+
return true
136+
})
137+
138+
const handler = getHandler('registry:install')
139+
await expect(handler(null, '@some/server')).rejects.toThrow('limit')
140+
})
141+
})
142+
})
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* @file src/main/ipc/__tests__/stacks.ipc.test.ts
3+
*
4+
* @created 07.03.2026
5+
* @modified 07.03.2026
6+
*
7+
* @author Christian Blank <christianblank91@protonmail.com>
8+
* @copyright 2026
9+
*
10+
* @description Unit tests for stacks IPC handlers. DB repos and feature gates
11+
* are backed by an in-memory SQLite database so tests verify real persistence
12+
* behavior without touching the filesystem.
13+
*/
14+
15+
import { describe, it, expect, vi, beforeEach } from 'vitest'
16+
import { createTestDb } from '@main/db/__tests__/helpers'
17+
import { getDatabase } from '@main/db/connection'
18+
import type { McpStack } from '@shared/channels'
19+
20+
// ─── Module Mocks ─────────────────────────────────────────────────────────────
21+
22+
vi.mock('electron', () => ({
23+
ipcMain: { handle: vi.fn() },
24+
}))
25+
26+
vi.mock('electron-log', () => ({
27+
default: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn() },
28+
}))
29+
30+
vi.mock('@main/db/connection', () => ({ getDatabase: vi.fn() }))
31+
32+
vi.mock('@main/licensing/feature-gates', () => ({
33+
checkGate: vi.fn().mockImplementation((key: string) => {
34+
if (key === 'stackExport') return true
35+
return true
36+
}),
37+
}))
38+
39+
// ─── Helpers ──────────────────────────────────────────────────────────────────
40+
41+
import { ipcMain } from 'electron'
42+
import { checkGate } from '@main/licensing/feature-gates'
43+
import { registerStacksIpc } from '../stacks.ipc'
44+
45+
type IpcHandler = (_event: unknown, ...args: unknown[]) => unknown
46+
47+
const getHandler = (channel: string): IpcHandler => {
48+
const calls = vi.mocked(ipcMain.handle).mock.calls
49+
const call = calls.find(([ch]) => ch === channel)
50+
if (!call) throw new Error(`No handler registered for "${channel}"`)
51+
return call[1] as IpcHandler
52+
}
53+
54+
const makeStack = (overrides: Partial<McpStack> = {}): string =>
55+
JSON.stringify({
56+
name: 'My Stack',
57+
description: '',
58+
version: '1.0.0',
59+
servers: [
60+
{
61+
name: 'test-server',
62+
type: 'stdio',
63+
command: 'npx',
64+
args: ['-y', '@test/mcp'],
65+
env: {},
66+
enabled: true,
67+
tags: [],
68+
notes: '',
69+
createdAt: new Date().toISOString(),
70+
updatedAt: new Date().toISOString(),
71+
},
72+
],
73+
rules: [
74+
{
75+
name: 'test-rule',
76+
description: '',
77+
content: '# Test rule',
78+
category: 'general',
79+
tags: [],
80+
enabled: true,
81+
priority: 'normal',
82+
scope: 'global',
83+
fileGlobs: [],
84+
alwaysApply: false,
85+
tokenEstimate: 5,
86+
createdAt: new Date().toISOString(),
87+
updatedAt: new Date().toISOString(),
88+
},
89+
],
90+
exportedAt: new Date().toISOString(),
91+
...overrides,
92+
} satisfies McpStack)
93+
94+
// ─── Tests ────────────────────────────────────────────────────────────────────
95+
96+
describe('stacks IPC handlers', () => {
97+
beforeEach(() => {
98+
vi.clearAllMocks()
99+
const db = createTestDb()
100+
vi.mocked(getDatabase).mockReturnValue(db)
101+
vi.mocked(checkGate).mockImplementation((key: string) => {
102+
if (key === 'stackExport') return true
103+
if (key === 'maxServers') return Infinity
104+
return true
105+
})
106+
registerStacksIpc()
107+
})
108+
109+
describe('stacks:export', () => {
110+
it('returns a JSON string with the correct structure', async () => {
111+
const handler = getHandler('stacks:export')
112+
// No servers/rules seeded — exporting empty selection is valid.
113+
const json = (await handler(null, [], [], 'Empty Stack')) as string
114+
115+
const stack = JSON.parse(json) as McpStack
116+
expect(stack.name).toBe('Empty Stack')
117+
expect(stack.version).toBe('1.0.0')
118+
expect(stack.servers).toEqual([])
119+
expect(stack.rules).toEqual([])
120+
expect(typeof stack.exportedAt).toBe('string')
121+
})
122+
123+
it('throws when the stackExport gate is false', async () => {
124+
vi.mocked(checkGate).mockReturnValue(false)
125+
126+
const handler = getHandler('stacks:export')
127+
await expect(handler(null, [], [], 'Stack')).rejects.toThrow('Pro')
128+
})
129+
})
130+
131+
describe('stacks:import', () => {
132+
it('imports servers and rules from a valid stack JSON', async () => {
133+
const handler = getHandler('stacks:import')
134+
const result = (await handler(null, makeStack())) as {
135+
imported: number
136+
skipped: number
137+
errors: string[]
138+
}
139+
140+
expect(result.imported).toBe(2) // 1 server + 1 rule
141+
expect(result.skipped).toBe(0)
142+
expect(result.errors).toHaveLength(0)
143+
})
144+
145+
it('skips duplicate server names on second import', async () => {
146+
const handler = getHandler('stacks:import')
147+
await handler(null, makeStack())
148+
const result = (await handler(null, makeStack())) as { imported: number; skipped: number }
149+
150+
expect(result.skipped).toBe(2)
151+
expect(result.imported).toBe(0)
152+
})
153+
154+
it('returns an error for invalid JSON', async () => {
155+
const handler = getHandler('stacks:import')
156+
const result = (await handler(null, 'not-json')) as { imported: number; errors: string[] }
157+
158+
expect(result.imported).toBe(0)
159+
expect(result.errors[0]).toMatch(/Invalid JSON/)
160+
})
161+
162+
it('returns an error for JSON missing servers/rules arrays', async () => {
163+
const handler = getHandler('stacks:import')
164+
const result = (await handler(null, JSON.stringify({ name: 'bad' }))) as { errors: string[] }
165+
166+
expect(result.errors[0]).toMatch(/Invalid stack format/)
167+
})
168+
})
169+
})

src/main/ipc/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { registerProfilesIpc } from './profiles.ipc'
2222
import { registerSecretsIpc } from './secrets.ipc'
2323
import { registerLicenseIpc } from './license.ipc'
2424
import { registerGitSyncIpc } from './git-sync.ipc'
25+
import { registerRegistryIpc } from './registry.ipc'
26+
import { registerStacksIpc } from './stacks.ipc'
2527

2628
/**
2729
* Registers all IPC handlers for every implemented domain.
@@ -36,5 +38,7 @@ export const registerIpcHandlers = (): void => {
3638
registerProfilesIpc()
3739
registerLicenseIpc()
3840
registerGitSyncIpc()
41+
registerRegistryIpc()
42+
registerStacksIpc()
3943
log.info('[ipc] all handlers registered')
4044
}

0 commit comments

Comments
 (0)