diff --git a/app/src/components/channels/__tests__/ChannelConfigPanel.test.tsx b/app/src/components/channels/__tests__/ChannelConfigPanel.test.tsx new file mode 100644 index 000000000..6e9ade2f8 --- /dev/null +++ b/app/src/components/channels/__tests__/ChannelConfigPanel.test.tsx @@ -0,0 +1,80 @@ +/** + * Tests for ChannelConfigPanel — covers the MCP virtual tab and the + * channel-definition-backed tabs (telegram, discord, web) and the null + * fallback when no matching definition is found. + */ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { FALLBACK_DEFINITIONS } from '../../../lib/channels/definitions'; +import { renderWithProviders } from '../../../test/test-utils'; +import ChannelConfigPanel from '../ChannelConfigPanel'; + +// McpServersTab is a heavy async component — mock it so ChannelConfigPanel +// tests stay focused on the routing logic (line 16 branch). +vi.mock('../mcp/McpServersTab', () => ({ + default: () =>
MCP Servers Tab
, +})); + +// Mock channel-specific config panels to keep tests lightweight. +vi.mock('../TelegramConfig', () => ({ + default: () =>
Telegram Config
, +})); + +vi.mock('../DiscordConfig', () => ({ + default: () =>
Discord Config
, +})); + +vi.mock('../WebChannelConfig', () => ({ + default: () =>
Web Config
, +})); + +vi.mock('../ChannelCapabilities', () => ({ + default: () =>
Capabilities
, +})); + +describe('ChannelConfigPanel', () => { + it('renders McpServersTab when selectedChannel is "mcp"', () => { + render(); + expect(screen.getByTestId('mcp-servers-tab')).toBeInTheDocument(); + expect(screen.getByText('MCP Servers')).toBeInTheDocument(); + }); + + it('does not render definition-based content when channel is "mcp"', () => { + render(); + // No Telegram/Discord/Web-specific config panels + expect(screen.queryByTestId('telegram-config')).not.toBeInTheDocument(); + expect(screen.queryByTestId('discord-config')).not.toBeInTheDocument(); + }); + + it('renders TelegramConfig when selectedChannel is "telegram"', () => { + renderWithProviders( + + ); + expect(screen.getByTestId('telegram-config')).toBeInTheDocument(); + }); + + it('renders DiscordConfig when selectedChannel is "discord"', () => { + renderWithProviders( + + ); + expect(screen.getByTestId('discord-config')).toBeInTheDocument(); + }); + + it('renders channel display_name and description for a matched definition', () => { + renderWithProviders( + + ); + expect(screen.getByText('Telegram')).toBeInTheDocument(); + expect(screen.getByText(/send and receive messages via telegram/i)).toBeInTheDocument(); + }); + + it('renders nothing when selectedChannel has no matching definition', () => { + const { container } = renderWithProviders( + // 'mcp' is handled above; use an unknown channel to hit the null-return + // branch (definition not found). + + ); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/app/src/components/channels/__tests__/ChannelSelector.test.tsx b/app/src/components/channels/__tests__/ChannelSelector.test.tsx index a49816874..4b51a6819 100644 --- a/app/src/components/channels/__tests__/ChannelSelector.test.tsx +++ b/app/src/components/channels/__tests__/ChannelSelector.test.tsx @@ -72,4 +72,53 @@ describe('ChannelSelector', () => { expect(within(telegramTab).getByText('Error')).toBeInTheDocument(); expect(within(telegramTab).queryByText('Disconnected')).not.toBeInTheDocument(); }); + + it('renders the MCP virtual tab', () => { + renderWithProviders( + + ); + expect(screen.getByRole('button', { name: /mcp servers/i })).toBeInTheDocument(); + }); + + it('calls onSelectChannel with "mcp" when MCP tab is clicked', () => { + const handleSelect = vi.fn(); + renderWithProviders( + + ); + fireEvent.click(screen.getByRole('button', { name: /mcp servers/i })); + expect(handleSelect).toHaveBeenCalledWith('mcp'); + }); + + it('applies selected styling to MCP tab when it is the active channel', () => { + renderWithProviders( + + ); + const mcpBtn = screen.getByRole('button', { name: /mcp servers/i }); + expect(mcpBtn.className).toContain('bg-primary-50'); + }); + + it('applies unselected styling to MCP tab when another channel is active', () => { + renderWithProviders( + + ); + const mcpBtn = screen.getByRole('button', { name: /mcp servers/i }); + expect(mcpBtn.className).not.toContain('bg-primary-50'); + expect(mcpBtn.className).toContain('bg-stone-50'); + }); }); diff --git a/app/src/components/channels/mcp/ConfigAssistantPanel.tsx b/app/src/components/channels/mcp/ConfigAssistantPanel.tsx index c52243a40..f936c3297 100644 --- a/app/src/components/channels/mcp/ConfigAssistantPanel.tsx +++ b/app/src/components/channels/mcp/ConfigAssistantPanel.tsx @@ -71,6 +71,8 @@ const ConfigAssistantPanel = ({ const msg = err instanceof Error ? err.message : 'Failed to get response'; log('config_assist error: %s', msg); setError(msg); + setMessages(messages); + setInput(text); } finally { setSending(false); } diff --git a/src/openhuman/mcp_clients/ops.rs b/src/openhuman/mcp_clients/ops.rs index d3ad207fb..90503f115 100644 --- a/src/openhuman/mcp_clients/ops.rs +++ b/src/openhuman/mcp_clients/ops.rs @@ -69,9 +69,25 @@ pub async fn mcp_clients_registry_get( .await .map_err(|e| e.to_string())?; + // Augment the response with required_env_keys derived from the connection + // config_schema so the frontend install dialog can build its input form. + let required_env_keys = collect_required_env_keys(&detail); + let mut server_value = + serde_json::to_value(&detail).map_err(|e| format!("serialization error: {e}"))?; + if let Some(obj) = server_value.as_object_mut() { + obj.insert( + "required_env_keys".to_string(), + serde_json::to_value(&required_env_keys).unwrap_or_else(|_| Value::Array(Vec::new())), + ); + } + Ok(RpcOutcome::new( - json!({ "server": detail }), - vec![format!("registry_get ok: {}", qualified_name.trim())], + json!({ "server": server_value }), + vec![format!( + "registry_get ok: {} env_keys={}", + qualified_name.trim(), + required_env_keys.len() + )], )) }