diff --git a/packages/cli/src/core/auth.test.ts b/packages/cli/src/core/auth.test.ts new file mode 100644 index 000000000..3f7b060d8 --- /dev/null +++ b/packages/cli/src/core/auth.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { performInitialAuth } from './auth.js'; + +const mockLogAuth = vi.fn(); +vi.mock('@qwen-code/qwen-code-core', () => ({ + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), + logAuth: (...args: unknown[]) => mockLogAuth(...args), + AuthEvent: vi.fn().mockImplementation((type, method, status, message?) => ({ + type, + method, + status, + message, + })), +})); + +describe('performInitialAuth', () => { + let mockConfig: { + refreshAuth: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = { + refreshAuth: vi.fn(), + }; + }); + + it('should return null when authType is undefined', async () => { + const result = await performInitialAuth(mockConfig as never, undefined); + + expect(result).toBeNull(); + expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); + expect(mockLogAuth).not.toHaveBeenCalled(); + }); + + it('should return null on successful authentication', async () => { + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const result = await performInitialAuth( + mockConfig as never, + 'api_key' as never, + ); + + expect(result).toBeNull(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith('api_key', true); + expect(mockLogAuth).toHaveBeenCalledTimes(1); + }); + + it('should return error message on authentication failure', async () => { + mockConfig.refreshAuth.mockRejectedValue(new Error('Invalid API key')); + + const result = await performInitialAuth( + mockConfig as never, + 'api_key' as never, + ); + + expect(result).toBe('Failed to login. Message: Invalid API key'); + expect(mockLogAuth).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index 7b1b92696..a8e921c2f 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -1,109 +1,190 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Code * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../config/settings.js'; -import type { Config } from '@qwen-code/qwen-code-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { initializeApp } from './initializer.js'; -import { performInitialAuth } from './auth.js'; -import { validateTheme } from './theme.js'; -import { initializeI18n } from '../i18n/index.js'; + +const mockPerformInitialAuth = vi.fn(); +const mockValidateTheme = vi.fn(); +const mockInitializeI18n = vi.fn(); vi.mock('./auth.js', () => ({ - performInitialAuth: vi.fn(), + performInitialAuth: (...args: unknown[]) => mockPerformInitialAuth(...args), })); vi.mock('./theme.js', () => ({ - validateTheme: vi.fn(), + validateTheme: (...args: unknown[]) => mockValidateTheme(...args), })); vi.mock('../i18n/index.js', () => ({ - initializeI18n: vi.fn(), + initializeI18n: (...args: unknown[]) => mockInitializeI18n(...args), })); +const mockConnect = vi.fn(); +const mockGetInstance = vi.fn().mockResolvedValue({ connect: mockConnect }); +const mockLogIdeConnection = vi.fn(); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + IdeClient: { getInstance: () => mockGetInstance() }, + IdeConnectionEvent: vi.fn().mockImplementation((type) => ({ type })), + IdeConnectionType: { START: 'start' }, + logIdeConnection: (...args: unknown[]) => mockLogIdeConnection(...args), + }; +}); + describe('initializeApp', () => { + let mockConfig: { + getModelsConfig: ReturnType; + getIdeMode: ReturnType; + getGeminiMdFileCount: ReturnType; + }; + let mockSettings: { + merged: Record; + setValue: ReturnType; + }; + beforeEach(() => { vi.clearAllMocks(); - delete process.env['QWEN_CODE_LANG']; - vi.mocked(initializeI18n).mockResolvedValue(undefined); - vi.mocked(validateTheme).mockReturnValue(null); - }); - - function createMockConfig( - options: { - authType?: AuthType; - wasAuthTypeExplicitlyProvided?: boolean; - geminiMdFileCount?: number; - ideMode?: boolean; - } = {}, - ): Config { - const { - authType = AuthType.USE_OPENAI, - wasAuthTypeExplicitlyProvided = true, - geminiMdFileCount = 0, - ideMode = false, - } = options; - - return { + mockConfig = { getModelsConfig: vi.fn().mockReturnValue({ - getCurrentAuthType: vi.fn().mockReturnValue(authType), - wasAuthTypeExplicitlyProvided: vi - .fn() - .mockReturnValue(wasAuthTypeExplicitlyProvided), + getCurrentAuthType: vi.fn().mockReturnValue('api_key'), + wasAuthTypeExplicitlyProvided: vi.fn().mockReturnValue(false), }), - getIdeMode: vi.fn().mockReturnValue(ideMode), - getGeminiMdFileCount: vi.fn().mockReturnValue(geminiMdFileCount), - } as unknown as Config; - } + getIdeMode: vi.fn().mockReturnValue(false), + getGeminiMdFileCount: vi.fn().mockReturnValue(0), + }; - function createMockSettings(): LoadedSettings { - return { - merged: { - general: { - language: 'en', - }, - }, + mockSettings = { + merged: { general: { language: 'en' } }, setValue: vi.fn(), - } as unknown as LoadedSettings; - } + }; - it('should not clear selected auth type when initial auth fails', async () => { - vi.mocked(performInitialAuth).mockResolvedValue( - 'Failed to login. Message: missing OLLAMA_API_KEY', - ); - - const config = createMockConfig({ - authType: AuthType.USE_OPENAI, - wasAuthTypeExplicitlyProvided: true, - }); - const settings = createMockSettings(); - - const result = await initializeApp(config, settings); - - expect(result.authError).toBe( - 'Failed to login. Message: missing OLLAMA_API_KEY', - ); - expect(result.shouldOpenAuthDialog).toBe(true); - expect(settings.setValue).not.toHaveBeenCalled(); + mockPerformInitialAuth.mockResolvedValue(null); + mockValidateTheme.mockReturnValue(null); + mockInitializeI18n.mockResolvedValue(undefined); }); - it('should not open auth dialog when auth is explicit and succeeds', async () => { - vi.mocked(performInitialAuth).mockResolvedValue(null); + it('should initialize i18n with language from settings', async () => { + await initializeApp(mockConfig as never, mockSettings as never); - const config = createMockConfig({ - authType: AuthType.USE_OPENAI, - wasAuthTypeExplicitlyProvided: true, - }); - const settings = createMockSettings(); + expect(mockInitializeI18n).toHaveBeenCalledWith('en'); + }); - const result = await initializeApp(config, settings); + it('should initialize i18n with QWEN_CODE_LANG env var if set', async () => { + vi.stubEnv('QWEN_CODE_LANG', 'zh'); + + await initializeApp(mockConfig as never, mockSettings as never); + expect(mockInitializeI18n).toHaveBeenCalledWith('zh'); + + vi.unstubAllEnvs(); + }); + + it('should return no errors on successful initialization', async () => { + const result = await initializeApp( + mockConfig as never, + mockSettings as never, + ); expect(result.authError).toBeNull(); + expect(result.themeError).toBeNull(); + expect(result.geminiMdFileCount).toBe(0); + }); + + it('should return authError when auth fails', async () => { + mockPerformInitialAuth.mockResolvedValue('Auth failed'); + + const result = await initializeApp( + mockConfig as never, + mockSettings as never, + ); + + expect(result.authError).toBe('Auth failed'); + expect(result.shouldOpenAuthDialog).toBe(true); + // initializeApp does not clear the selected auth type on failure + expect(mockSettings.setValue).not.toHaveBeenCalled(); + }); + + it('should return themeError when theme validation fails', async () => { + mockValidateTheme.mockReturnValue('Theme not found'); + + const result = await initializeApp( + mockConfig as never, + mockSettings as never, + ); + + expect(result.themeError).toBe('Theme not found'); + }); + + it('should set shouldOpenAuthDialog when auth was not explicitly provided', async () => { + mockConfig + .getModelsConfig() + .wasAuthTypeExplicitlyProvided.mockReturnValue(false); + + const result = await initializeApp( + mockConfig as never, + mockSettings as never, + ); + + expect(result.shouldOpenAuthDialog).toBe(true); + }); + + it('should set shouldOpenAuthDialog when auth error occurs', async () => { + mockConfig + .getModelsConfig() + .wasAuthTypeExplicitlyProvided.mockReturnValue(true); + mockPerformInitialAuth.mockResolvedValue('Auth failed'); + + const result = await initializeApp( + mockConfig as never, + mockSettings as never, + ); + + expect(result.shouldOpenAuthDialog).toBe(true); + }); + + it('should not open auth dialog when auth was explicitly provided and succeeds', async () => { + mockConfig + .getModelsConfig() + .wasAuthTypeExplicitlyProvided.mockReturnValue(true); + + const result = await initializeApp( + mockConfig as never, + mockSettings as never, + ); + expect(result.shouldOpenAuthDialog).toBe(false); }); + + it('should connect to IDE when in IDE mode', async () => { + mockConfig.getIdeMode.mockReturnValue(true); + + await initializeApp(mockConfig as never, mockSettings as never); + + expect(mockGetInstance).toHaveBeenCalled(); + expect(mockConnect).toHaveBeenCalled(); + expect(mockLogIdeConnection).toHaveBeenCalled(); + }); + + it('should not connect to IDE when not in IDE mode', async () => { + mockConfig.getIdeMode.mockReturnValue(false); + + await initializeApp(mockConfig as never, mockSettings as never); + + expect(mockGetInstance).not.toHaveBeenCalled(); + }); + + it('should default language to auto when no setting is provided', async () => { + mockSettings.merged = {}; + + await initializeApp(mockConfig as never, mockSettings as never); + + expect(mockInitializeI18n).toHaveBeenCalledWith('auto'); + }); }); diff --git a/packages/cli/src/core/theme.test.ts b/packages/cli/src/core/theme.test.ts new file mode 100644 index 000000000..c0c4a3cab --- /dev/null +++ b/packages/cli/src/core/theme.test.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { validateTheme } from './theme.js'; + +const mockFindThemeByName = vi.fn(); +vi.mock('../ui/themes/theme-manager.js', () => ({ + themeManager: { + findThemeByName: (...args: unknown[]) => mockFindThemeByName(...args), + }, +})); + +vi.mock('../i18n/index.js', () => ({ + t: (msg: string, params?: Record) => { + if (params) { + return msg.replace( + /\{\{(\w+)\}\}/g, + (_, key) => params[key] ?? `{{${key}}}`, + ); + } + return msg; + }, +})); + +describe('validateTheme', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return null when no theme is configured', () => { + const settings = { merged: { ui: {} } }; + const result = validateTheme(settings as never); + expect(result).toBeNull(); + }); + + it('should return null when theme is found', () => { + mockFindThemeByName.mockReturnValue({ name: 'dark' }); + const settings = { merged: { ui: { theme: 'dark' } } }; + + const result = validateTheme(settings as never); + + expect(result).toBeNull(); + expect(mockFindThemeByName).toHaveBeenCalledWith('dark'); + }); + + it('should return error message when theme is not found', () => { + mockFindThemeByName.mockReturnValue(undefined); + const settings = { merged: { ui: { theme: 'nonexistent-theme' } } }; + + const result = validateTheme(settings as never); + + expect(result).toBe('Theme "nonexistent-theme" not found.'); + }); + + it('should return null when ui section is undefined', () => { + const settings = { merged: {} }; + const result = validateTheme(settings as never); + expect(result).toBeNull(); + }); +}); diff --git a/packages/core/src/confirmation-bus/message-bus.test.ts b/packages/core/src/confirmation-bus/message-bus.test.ts new file mode 100644 index 000000000..d51f72ee7 --- /dev/null +++ b/packages/core/src/confirmation-bus/message-bus.test.ts @@ -0,0 +1,319 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MessageBus } from './message-bus.js'; +import { MessageBusType } from './types.js'; +import type { + HookExecutionRequest, + HookExecutionResponse, + Message, + ToolConfirmationRequest, + ToolConfirmationResponse, + ToolExecutionSuccess, +} from './types.js'; + +vi.mock('../utils/debugLogger.js', () => ({ + createDebugLogger: () => ({ + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('../utils/safeJsonStringify.js', () => ({ + safeJsonStringify: (obj: unknown) => JSON.stringify(obj), +})); + +describe('MessageBus', () => { + let bus: MessageBus; + + beforeEach(() => { + bus = new MessageBus(); + }); + + afterEach(() => { + bus.removeAllListeners(); + }); + + describe('publish', () => { + it('should auto-confirm tool confirmation requests', async () => { + const responses: ToolConfirmationResponse[] = []; + bus.subscribe( + MessageBusType.TOOL_CONFIRMATION_RESPONSE, + (msg) => responses.push(msg), + ); + + const request: ToolConfirmationRequest = { + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + toolCall: { name: 'test_tool', args: {} }, + correlationId: 'test-123', + }; + + await bus.publish(request); + + expect(responses).toHaveLength(1); + expect(responses[0].confirmed).toBe(true); + expect(responses[0].correlationId).toBe('test-123'); + }); + + it('should emit hook execution requests directly', async () => { + const received: HookExecutionRequest[] = []; + bus.subscribe( + MessageBusType.HOOK_EXECUTION_REQUEST, + (msg) => received.push(msg), + ); + + const request: HookExecutionRequest = { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'UserPromptSubmit', + input: { prompt: 'test' }, + correlationId: 'hook-123', + }; + + await bus.publish(request); + + expect(received).toHaveLength(1); + expect(received[0].eventName).toBe('UserPromptSubmit'); + expect(received[0].correlationId).toBe('hook-123'); + }); + + it('should emit other message types directly', async () => { + const received: ToolExecutionSuccess[] = []; + bus.subscribe( + MessageBusType.TOOL_EXECUTION_SUCCESS, + (msg) => received.push(msg), + ); + + const message: ToolExecutionSuccess = { + type: MessageBusType.TOOL_EXECUTION_SUCCESS, + toolCall: { name: 'test_tool', args: {} }, + result: { data: 'test' }, + }; + + await bus.publish(message); + + expect(received).toHaveLength(1); + expect(received[0].result).toEqual({ data: 'test' }); + }); + + it('should emit error for invalid messages', async () => { + const errors: Error[] = []; + bus.on('error', (err) => errors.push(err)); + + await bus.publish(null as unknown as Message); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Invalid message structure'); + }); + + it('should emit error for tool confirmation request without correlationId', async () => { + const errors: Error[] = []; + bus.on('error', (err) => errors.push(err)); + + await bus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + toolCall: { name: 'test', args: {} }, + } as unknown as Message); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Invalid message structure'); + }); + + it('should emit error for message without type', async () => { + const errors: Error[] = []; + bus.on('error', (err) => errors.push(err)); + + await bus.publish({} as unknown as Message); + + expect(errors).toHaveLength(1); + }); + }); + + describe('subscribe / unsubscribe', () => { + it('should subscribe and receive messages', async () => { + const received: HookExecutionResponse[] = []; + const listener = (msg: HookExecutionResponse) => received.push(msg); + bus.subscribe( + MessageBusType.HOOK_EXECUTION_RESPONSE, + listener, + ); + + const response: HookExecutionResponse = { + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: 'resp-123', + success: true, + }; + + await bus.publish(response); + expect(received).toHaveLength(1); + }); + + it('should unsubscribe and stop receiving messages', async () => { + const received: HookExecutionResponse[] = []; + const listener = (msg: HookExecutionResponse) => received.push(msg); + bus.subscribe( + MessageBusType.HOOK_EXECUTION_RESPONSE, + listener, + ); + bus.unsubscribe( + MessageBusType.HOOK_EXECUTION_RESPONSE, + listener, + ); + + const response: HookExecutionResponse = { + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: 'resp-123', + success: true, + }; + + await bus.publish(response); + expect(received).toHaveLength(0); + }); + }); + + describe('request', () => { + it('should correlate request and response', async () => { + // Set up a handler that responds to hook execution requests + bus.subscribe( + MessageBusType.HOOK_EXECUTION_REQUEST, + (msg) => { + void bus.publish({ + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: msg.correlationId, + success: true, + output: { result: 'done' }, + }); + }, + ); + + const response = await bus.request< + HookExecutionRequest, + HookExecutionResponse + >( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'TestEvent', + input: {}, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + ); + + expect(response.success).toBe(true); + expect(response.output).toEqual({ result: 'done' }); + }); + + it('should ignore responses with non-matching correlationId', async () => { + // Emit a response with wrong correlation ID, then the correct one + bus.subscribe( + MessageBusType.HOOK_EXECUTION_REQUEST, + (msg) => { + // First emit a wrong correlation ID + void bus.publish({ + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: 'wrong-id', + success: false, + }); + // Then emit the correct one + void bus.publish({ + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: msg.correlationId, + success: true, + }); + }, + ); + + const response = await bus.request< + HookExecutionRequest, + HookExecutionResponse + >( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'TestEvent', + input: {}, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + ); + + expect(response.success).toBe(true); + }); + + it('should timeout if no response is received', async () => { + await expect( + bus.request( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'TestEvent', + input: {}, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + 50, // 50ms timeout + ), + ).rejects.toThrow('Request timed out'); + }); + + it('should reject immediately when signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + bus.request( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'TestEvent', + input: {}, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + 5000, + controller.signal, + ), + ).rejects.toThrow('Request aborted'); + }); + + it('should reject when signal is aborted during wait', async () => { + const controller = new AbortController(); + + const promise = bus.request( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'TestEvent', + input: {}, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + 5000, + controller.signal, + ); + + // Abort after a tick + setTimeout(() => controller.abort(), 10); + + await expect(promise).rejects.toThrow('Request aborted'); + }); + + it('should auto-confirm tool confirmation via request pattern', async () => { + const response = await bus.request< + ToolConfirmationRequest, + ToolConfirmationResponse + >( + { + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + toolCall: { name: 'test_tool', args: {} }, + }, + MessageBusType.TOOL_CONFIRMATION_RESPONSE, + ); + + expect(response.confirmed).toBe(true); + }); + }); + + describe('debug mode', () => { + it('should create MessageBus with debug enabled', () => { + const debugBus = new MessageBus(true); + expect(debugBus).toBeInstanceOf(MessageBus); + debugBus.removeAllListeners(); + }); + }); +}); diff --git a/packages/core/src/prompts/prompt-registry.test.ts b/packages/core/src/prompts/prompt-registry.test.ts new file mode 100644 index 000000000..c11a1312a --- /dev/null +++ b/packages/core/src/prompts/prompt-registry.test.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PromptRegistry } from './prompt-registry.js'; +import type { DiscoveredMCPPrompt } from '../tools/mcp-client.js'; + +vi.mock('../utils/debugLogger.js', () => ({ + createDebugLogger: () => ({ + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +function makePrompt(name: string, serverName: string): DiscoveredMCPPrompt { + return { + name, + serverName, + invoke: vi.fn(), + }; +} + +describe('PromptRegistry', () => { + let registry: PromptRegistry; + + beforeEach(() => { + registry = new PromptRegistry(); + }); + + describe('registerPrompt', () => { + it('should register a prompt by name', () => { + const prompt = makePrompt('greet', 'server-a'); + registry.registerPrompt(prompt); + + expect(registry.getPrompt('greet')).toBe(prompt); + }); + + it('should rename duplicate prompts with server prefix', () => { + const prompt1 = makePrompt('greet', 'server-a'); + const prompt2 = makePrompt('greet', 'server-b'); + + registry.registerPrompt(prompt1); + registry.registerPrompt(prompt2); + + expect(registry.getPrompt('greet')).toBe(prompt1); + const renamed = registry.getPrompt('server-b_greet'); + expect(renamed).toBeDefined(); + expect(renamed!.serverName).toBe('server-b'); + expect(renamed!.name).toBe('server-b_greet'); + expect(renamed!.invoke).toBe(prompt2.invoke); + }); + }); + + describe('getAllPrompts', () => { + it('should return empty array when no prompts registered', () => { + expect(registry.getAllPrompts()).toEqual([]); + }); + + it('should return all prompts sorted by name', () => { + registry.registerPrompt(makePrompt('zulu', 'server-a')); + registry.registerPrompt(makePrompt('alpha', 'server-a')); + registry.registerPrompt(makePrompt('mike', 'server-b')); + + const all = registry.getAllPrompts(); + expect(all.map((p) => p.name)).toEqual(['alpha', 'mike', 'zulu']); + }); + }); + + describe('getPrompt', () => { + it('should return undefined for non-existent prompt', () => { + expect(registry.getPrompt('nonexistent')).toBeUndefined(); + }); + }); + + describe('getPromptsByServer', () => { + it('should return prompts from a specific server', () => { + registry.registerPrompt(makePrompt('a', 'server-a')); + registry.registerPrompt(makePrompt('b', 'server-a')); + registry.registerPrompt(makePrompt('c', 'server-b')); + + const serverAPrompts = registry.getPromptsByServer('server-a'); + expect(serverAPrompts).toHaveLength(2); + expect(serverAPrompts.map((p) => p.name)).toEqual(['a', 'b']); + }); + + it('should return empty array for unknown server', () => { + expect(registry.getPromptsByServer('unknown')).toEqual([]); + }); + + it('should return prompts sorted by name', () => { + registry.registerPrompt(makePrompt('z-prompt', 'server-a')); + registry.registerPrompt(makePrompt('a-prompt', 'server-a')); + + const prompts = registry.getPromptsByServer('server-a'); + expect(prompts.map((p) => p.name)).toEqual(['a-prompt', 'z-prompt']); + }); + }); + + describe('clear', () => { + it('should remove all prompts', () => { + registry.registerPrompt(makePrompt('a', 'server-a')); + registry.registerPrompt(makePrompt('b', 'server-b')); + + registry.clear(); + + expect(registry.getAllPrompts()).toEqual([]); + }); + }); + + describe('removePromptsByServer', () => { + it('should remove only prompts from the specified server', () => { + registry.registerPrompt(makePrompt('a', 'server-a')); + registry.registerPrompt(makePrompt('b', 'server-a')); + registry.registerPrompt(makePrompt('c', 'server-b')); + + registry.removePromptsByServer('server-a'); + + const remaining = registry.getAllPrompts(); + expect(remaining).toHaveLength(1); + expect(remaining[0].name).toBe('c'); + }); + + it('should do nothing for unknown server', () => { + registry.registerPrompt(makePrompt('a', 'server-a')); + + registry.removePromptsByServer('unknown'); + + expect(registry.getAllPrompts()).toHaveLength(1); + }); + }); +});