Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/desktop/src/components/ServerIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Shared server icon component that handles both URL-based and emoji icons.
*
* Server definitions may have an `icon` field that is either:
* - An HTTP(S) URL to an image (e.g., GitHub avatar)
* - An emoji string (e.g., "πŸ“¦")
* - null/undefined
*/

import { useState } from 'react';

interface ServerIconProps {
icon: string | null | undefined;
/** CSS classes for the img element when rendering a URL icon */
className?: string;
/** Fallback emoji when icon is missing or fails to load (default: 'πŸ“¦') */
fallback?: string;
}

export function ServerIcon({ icon, className = 'w-9 h-9 object-contain', fallback = 'πŸ“¦' }: ServerIconProps) {
const [failed, setFailed] = useState(false);

if (!icon || failed) {
return <span data-testid="server-icon-fallback">{fallback}</span>;
}

if (icon.startsWith('http')) {
return (
<img
src={icon}
alt=""
className={className}
data-testid="server-icon-img"
onError={() => setFailed(true)}
/>
);
}

return <span data-testid="server-icon-emoji">{icon}</span>;
}
7 changes: 4 additions & 3 deletions apps/desktop/src/components/ServerInstallModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from '@/lib/api/registry';
import type { ServerDefinition } from '@/types/registry';
import { useViewSpace } from '@/stores';
import { ServerIcon } from '@/components/ServerIcon';

/** Deep link payload from backend */
interface ServerInstallDeepLinkPayload {
Expand Down Expand Up @@ -243,9 +244,9 @@ export function ServerInstallModal() {
{/* Server Info */}
<div className="p-4 rounded-lg bg-surface-hover border border-[rgb(var(--border))]" data-testid="install-modal-server-info">
<div className="flex items-center gap-3">
{server.icon && (
<span className="text-2xl">{server.icon}</span>
)}
<div className="flex-shrink-0 flex items-center justify-center text-2xl">
<ServerIcon icon={server.icon} className="w-8 h-8 object-contain rounded" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-lg" data-testid="install-modal-server-name">{server.name}</div>
{server.description && (
Expand Down
7 changes: 2 additions & 5 deletions apps/desktop/src/features/registry/ServerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { ServerViewModel } from '../../types/registry';
import { ServerIcon } from '../../components/ServerIcon';

interface ServerCardProps {
server: ServerViewModel;
Expand Down Expand Up @@ -103,11 +104,7 @@ export function ServerCard({
{/* Header */}
<div className="flex items-start gap-3 mb-3">
<div className="text-3xl flex-shrink-0 flex items-center justify-center">
{server.icon?.startsWith('http') ? (
<img src={server.icon} alt="" className="w-9 h-9 object-contain" onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.parentElement!.append(document.createTextNode('πŸ“¦')); }} />
) : (
server.icon || 'πŸ“¦'
)}
<ServerIcon icon={server.icon} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/features/registry/ServerDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { ServerViewModel } from '../../types/registry';
import { ServerIcon } from '../../components/ServerIcon';

interface ServerDetailModalProps {
server: ServerViewModel;
Expand Down Expand Up @@ -31,7 +32,9 @@ export function ServerDetailModal({
<div className="dropdown-menu relative w-full max-w-lg max-h-[90vh] overflow-hidden animate-in fade-in scale-in duration-150">
{/* Header */}
<div className="flex items-start gap-4 p-6 border-b border-[rgb(var(--border))]">
<div className="text-5xl">{server.icon || 'πŸ“¦'}</div>
<div className="flex-shrink-0 flex items-center justify-center">
<ServerIcon icon={server.icon} className="w-12 h-12 object-contain rounded-lg" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-xl font-bold">
Expand Down
74 changes: 74 additions & 0 deletions tests/e2e/specs/registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,80 @@ test.describe('Registry/Discover Page', () => {
});
});

test.describe('Registry Server Icon Rendering', () => {
test('should render server icons as images not raw URLs', async ({ page }) => {
const dashboard = new DashboardPage(page);

await dashboard.navigate();
await page.locator('nav button:has-text("Discover")').click();

// Wait for content to load
await page.waitForTimeout(500);

// Server cards with URL icons should render img elements, not raw URL text
const serverIconImages = page.locator('[data-testid="server-icon-img"]');
const serverIconFallbacks = page.locator('[data-testid="server-icon-fallback"]');
const serverIconEmojis = page.locator('[data-testid="server-icon-emoji"]');

const imgCount = await serverIconImages.count();
const fallbackCount = await serverIconFallbacks.count();
const emojiCount = await serverIconEmojis.count();

// At least some icons should be rendered (either as img or fallback/emoji)
expect(imgCount + fallbackCount + emojiCount).toBeGreaterThan(0);

// Verify img elements have valid src attributes
if (imgCount > 0) {
const firstImg = serverIconImages.first();
const src = await firstImg.getAttribute('src');
expect(src).toMatch(/^https?:\/\//);
}

// Ensure no raw URL text is shown in place of icons
const cardTexts = await page.locator('[data-testid^="server-card-"]').allTextContents();
for (const text of cardTexts) {
expect(text).not.toMatch(/^https?:\/\/avatars\./);
}
});

test('should render icon as img in server detail modal', async ({ page }) => {
const dashboard = new DashboardPage(page);
const registry = new RegistryPage(page);

await dashboard.navigate();
await page.locator('nav button:has-text("Discover")').click();
await page.waitForTimeout(500);

// Click first server card to open detail modal
const firstCard = page.locator('[data-testid^="server-card-"]').first();
if (await firstCard.isVisible().catch(() => false)) {
await firstCard.click();
await page.waitForTimeout(300);

// The detail modal should render icons properly
const modalIconImg = page.locator('.fixed [data-testid="server-icon-img"]');
const modalIconFallback = page.locator('.fixed [data-testid="server-icon-fallback"]');
const modalIconEmoji = page.locator('.fixed [data-testid="server-icon-emoji"]');

const hasImg = await modalIconImg.isVisible().catch(() => false);
const hasFallback = await modalIconFallback.isVisible().catch(() => false);
const hasEmoji = await modalIconEmoji.isVisible().catch(() => false);

// At least one icon rendering approach should be used
expect(hasImg || hasFallback || hasEmoji).toBe(true);

// If img, verify it has valid src
if (hasImg) {
const src = await modalIconImg.getAttribute('src');
expect(src).toMatch(/^https?:\/\//);
}

// Close modal
await page.keyboard.press('Escape');
}
});
});

test.describe('Registry Filters and Sorting', () => {
test('should have filter elements', async ({ page }) => {
const dashboard = new DashboardPage(page);
Expand Down
130 changes: 130 additions & 0 deletions tests/ts/components/ServerCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ServerCard } from '../../../apps/desktop/src/features/registry/ServerCard';
import type { ServerViewModel } from '../../../apps/desktop/src/types/registry';

function makeServer(overrides: Partial<ServerViewModel> = {}): ServerViewModel {
return {
id: 'com.test-server',
name: 'Test Server',
description: 'A test MCP server',
alias: 'test',
icon: null,
auth: { type: 'none' },
transport: {
type: 'http',
url: 'https://example.com/mcp',
headers: {},
metadata: { inputs: [] },
},
categories: ['developer-tools'],
publisher: null,
source: { type: 'Registry', url: 'https://registry.mcpmux.com', name: 'McpMux Registry' },
is_installed: false,
enabled: false,
oauth_connected: false,
input_values: {},
connection_status: 'disconnected',
missing_required_inputs: false,
last_error: null,
...overrides,
};
}

describe('ServerCard', () => {
const defaultProps = {
onInstall: vi.fn(),
onUninstall: vi.fn(),
onViewDetails: vi.fn(),
};

describe('icon rendering', () => {
it('should render fallback icon when icon is null', () => {
const server = makeServer({ icon: null });
render(<ServerCard server={server} {...defaultProps} />);
expect(screen.getByTestId('server-icon-fallback')).toHaveTextContent('πŸ“¦');
});

it('should render emoji icon as text', () => {
const server = makeServer({ icon: 'πŸ”' });
render(<ServerCard server={server} {...defaultProps} />);
expect(screen.getByTestId('server-icon-emoji')).toHaveTextContent('πŸ”');
});

it('should render URL icon as img element', () => {
const server = makeServer({
icon: 'https://avatars.githubusercontent.com/u/314135?v=4',
});
render(<ServerCard server={server} {...defaultProps} />);
const img = screen.getByTestId('server-icon-img');
expect(img.tagName).toBe('IMG');
expect(img).toHaveAttribute(
'src',
'https://avatars.githubusercontent.com/u/314135?v=4'
);
});

it('should show fallback when image fails to load', () => {
const server = makeServer({
icon: 'https://example.com/broken-icon.png',
});
render(<ServerCard server={server} {...defaultProps} />);
const img = screen.getByTestId('server-icon-img');
fireEvent.error(img);
expect(screen.getByTestId('server-icon-fallback')).toHaveTextContent('πŸ“¦');
});
});

describe('server info', () => {
it('should render server name', () => {
const server = makeServer({ name: 'GitHub MCP Server' });
render(<ServerCard server={server} {...defaultProps} />);
expect(screen.getByText('GitHub MCP Server')).toBeInTheDocument();
});

it('should render description', () => {
const server = makeServer({ description: 'Manage GitHub repos' });
render(<ServerCard server={server} {...defaultProps} />);
expect(screen.getByText('Manage GitHub repos')).toBeInTheDocument();
});

it('should render categories', () => {
const server = makeServer({
categories: ['cloud', 'developer-tools', 'productivity'],
});
render(<ServerCard server={server} {...defaultProps} />);
expect(screen.getByText('cloud')).toBeInTheDocument();
expect(screen.getByText('developer-tools')).toBeInTheDocument();
expect(screen.getByText('productivity')).toBeInTheDocument();
});

it('should truncate categories beyond 3', () => {
const server = makeServer({
categories: ['cloud', 'developer-tools', 'productivity', 'extra'],
});
render(<ServerCard server={server} {...defaultProps} />);
expect(screen.getByText('+1')).toBeInTheDocument();
});
});

describe('actions', () => {
it('should render Install button for non-installed server', () => {
const server = makeServer({ is_installed: false });
render(<ServerCard server={server} {...defaultProps} />);
expect(screen.getByText('Install')).toBeInTheDocument();
});

it('should render Uninstall button for installed server', () => {
const server = makeServer({ is_installed: true });
render(<ServerCard server={server} {...defaultProps} />);
expect(screen.getByText('Uninstall')).toBeInTheDocument();
});

it('should call onViewDetails when card is clicked', () => {
const server = makeServer();
render(<ServerCard server={server} {...defaultProps} />);
fireEvent.click(screen.getByTestId(`server-card-${server.id}`));
expect(defaultProps.onViewDetails).toHaveBeenCalledWith(server);
});
});
});
Loading
Loading