🚦 feat: Simplify MCP UI integration and add unit tests (#9418)

This commit is contained in:
Samuel Path 2025-09-03 08:21:12 +02:00 committed by GitHub
parent f9b12517b0
commit 6d791e3e12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 953 additions and 20 deletions

View file

@ -1,7 +1,7 @@
import React from 'react';
import { useLocalize } from '~/hooks';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceGrid from './UIResourceGrid';
import UIResourceCarousel from './UIResourceCarousel';
import type { UIResource } from '~/common';
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
@ -57,16 +57,21 @@ export default function ToolCallInfo({
// Extract ui_resources from the output to display them in the UI
let uiResources: UIResource[] = [];
if (output?.includes('ui_resources')) {
const parsedOutput = JSON.parse(output);
const uiResourcesItem = parsedOutput.find(
(contentItem) => contentItem.metadata === 'ui_resources',
);
if (uiResourcesItem?.text) {
uiResources = JSON.parse(atob(uiResourcesItem.text)) as UIResource[];
try {
const parsedOutput = JSON.parse(output);
const uiResourcesItem = parsedOutput.find(
(contentItem) => contentItem.metadata?.type === 'ui_resources',
);
if (uiResourcesItem?.metadata?.data) {
uiResources = uiResourcesItem.metadata.data;
output = JSON.stringify(
parsedOutput.filter((contentItem) => contentItem.metadata?.type !== 'ui_resources'),
);
}
} catch (error) {
// If JSON parsing fails, keep original output
console.error('Failed to parse output:', error);
}
output = JSON.stringify(
parsedOutput.filter((contentItem) => contentItem.metadata !== 'ui_resources'),
);
}
return (
@ -90,7 +95,7 @@ export default function ToolCallInfo({
</div>
)}
<div>
{uiResources.length > 1 && <UIResourceGrid uiResources={uiResources} />}
{uiResources.length > 1 && <UIResourceCarousel uiResources={uiResources} />}
{uiResources.length === 1 && (
<UIResourceRenderer

View file

@ -2,11 +2,11 @@ import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from '~/common';
import React, { useState } from 'react';
interface UIResourceGridProps {
interface UIResourceCarouselProps {
uiResources: UIResource[];
}
const UIResourceGrid: React.FC<UIResourceGridProps> = React.memo(({ uiResources }) => {
const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiResources }) => {
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(true);
const [isContainerHovered, setIsContainerHovered] = useState(false);
@ -142,4 +142,4 @@ const UIResourceGrid: React.FC<UIResourceGridProps> = React.memo(({ uiResources
);
});
export default UIResourceGrid;
export default UIResourceCarousel;

View file

@ -0,0 +1,273 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ToolCallInfo from '../ToolCallInfo';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from '../UIResourceCarousel';
// Mock the dependencies
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: any) => {
const translations: Record<string, string> = {
com_assistants_domain_info: `Used ${values?.[0]}`,
com_assistants_function_use: `Used ${values?.[0]}`,
com_assistants_action_attempt: `Attempted to use ${values?.[0]}`,
com_assistants_attempt_info: 'Attempted to use function',
com_ui_result: 'Result',
com_ui_ui_resources: 'UI Resources',
};
return translations[key] || key;
},
}));
jest.mock('@mcp-ui/client', () => ({
UIResourceRenderer: jest.fn(() => null),
}));
jest.mock('../UIResourceCarousel', () => ({
__esModule: true,
default: jest.fn(() => null),
}));
// Add TextEncoder/TextDecoder polyfill for Jest environment
import { TextEncoder, TextDecoder } from 'util';
if (typeof global.TextEncoder === 'undefined') {
global.TextEncoder = TextEncoder as any;
global.TextDecoder = TextDecoder as any;
}
describe('ToolCallInfo', () => {
const mockProps = {
input: '{"test": "input"}',
function_name: 'testFunction',
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('ui_resources extraction', () => {
it('should extract single ui_resource from output', () => {
const uiResource = {
type: 'text',
data: 'Test resource',
};
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
metadata: {
type: 'ui_resources',
data: [uiResource],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Should render UIResourceRenderer for single resource
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: uiResource,
onUIAction: expect.any(Function),
htmlProps: {
autoResizeIframe: { width: true, height: true },
},
}),
expect.any(Object),
);
// Should not render carousel for single resource
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should extract multiple ui_resources from output', () => {
const uiResources = [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
];
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
metadata: {
type: 'ui_resources',
data: uiResources,
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Should render carousel for multiple resources
expect(UIResourceCarousel).toHaveBeenCalledWith(
expect.objectContaining({
uiResources,
}),
expect.any(Object),
);
// Should not render individual UIResourceRenderer
expect(UIResourceRenderer).not.toHaveBeenCalled();
});
it('should filter out ui_resources from displayed output', () => {
const regularContent = [
{ type: 'text', text: 'Regular output 1' },
{ type: 'text', text: 'Regular output 2' },
];
const output = JSON.stringify([
...regularContent,
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'UI Resource' }],
},
},
]);
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
// Check that the displayed output doesn't contain ui_resources
const codeBlocks = container.querySelectorAll('code');
const outputCode = codeBlocks[1]?.textContent; // Second code block is the output
expect(outputCode).toContain('Regular output 1');
expect(outputCode).toContain('Regular output 2');
expect(outputCode).not.toContain('ui_resources');
});
it('should handle output without ui_resources', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />);
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should handle malformed ui_resources gracefully', () => {
const output = JSON.stringify([
{
metadata: 'ui_resources', // metadata should be an object, not a string
text: 'some text content',
},
]);
// Component should not throw error and should render without UI resources
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
// Should render the component without crashing
expect(container).toBeTruthy();
// UIResourceCarousel should not be called since the metadata structure is invalid
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should handle ui_resources as plain text without breaking', () => {
const outputWithTextOnly =
'This output contains ui_resources as plain text but not as a proper structure';
render(<ToolCallInfo {...mockProps} output={outputWithTextOnly} />);
// Should render normally without errors
expect(screen.getByText(`Used ${mockProps.function_name}`)).toBeInTheDocument();
expect(screen.getByText('Result')).toBeInTheDocument();
// The output text should be displayed in a code block
const codeBlocks = screen.getAllByText((content, element) => {
return element?.tagName === 'CODE' && content.includes(outputWithTextOnly);
});
expect(codeBlocks.length).toBeGreaterThan(0);
// Should not render UI resources components
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
});
describe('rendering logic', () => {
it('should render UI Resources heading when ui_resources exist', () => {
const output = JSON.stringify([
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'Test' }],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
expect(screen.getByText('UI Resources')).toBeInTheDocument();
});
it('should not render UI Resources heading when no ui_resources', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />);
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
});
it('should pass correct props to UIResourceRenderer', () => {
const uiResource = {
type: 'form',
data: { fields: [{ name: 'test', type: 'text' }] },
};
const output = JSON.stringify([
{
metadata: {
type: 'ui_resources',
data: [uiResource],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: uiResource,
onUIAction: expect.any(Function),
htmlProps: {
autoResizeIframe: { width: true, height: true },
},
}),
expect.any(Object),
);
});
it('should console.log when UIAction is triggered', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const output = JSON.stringify([
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'Test' }],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
typeof UIResourceRenderer
>;
const onUIAction = mockUIResourceRenderer.mock.calls[0]?.[0]?.onUIAction;
const testResult = { action: 'submit', data: { test: 'value' } };
if (onUIAction) {
await onUIAction(testResult as any);
}
expect(consoleSpy).toHaveBeenCalledWith('Action:', testResult);
consoleSpy.mockRestore();
});
});
});

View file

@ -0,0 +1,219 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UIResourceCarousel from '../UIResourceCarousel';
import type { UIResource } from '~/common';
// Mock the UIResourceRenderer component
jest.mock('@mcp-ui/client', () => ({
UIResourceRenderer: ({ resource, onUIAction }: any) => (
<div data-testid="ui-resource-renderer" onClick={() => onUIAction({ action: 'test' })}>
{resource.text || 'UI Resource'}
</div>
),
}));
// Mock scrollTo
const mockScrollTo = jest.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: mockScrollTo,
});
describe('UIResourceCarousel', () => {
const mockUIResources: UIResource[] = [
{ uri: 'resource1', mimeType: 'text/html', text: 'Resource 1' },
{ uri: 'resource2', mimeType: 'text/html', text: 'Resource 2' },
{ uri: 'resource3', mimeType: 'text/html', text: 'Resource 3' },
{ uri: 'resource4', mimeType: 'text/html', text: 'Resource 4' },
{ uri: 'resource5', mimeType: 'text/html', text: 'Resource 5' },
];
beforeEach(() => {
jest.clearAllMocks();
// Reset scroll properties
Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
configurable: true,
value: 0,
});
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
value: 500,
});
});
it('renders nothing when no resources provided', () => {
const { container } = render(<UIResourceCarousel uiResources={[]} />);
expect(container.firstChild).toBeNull();
});
it('renders all UI resources', () => {
render(<UIResourceCarousel uiResources={mockUIResources} />);
const renderers = screen.getAllByTestId('ui-resource-renderer');
expect(renderers).toHaveLength(5);
expect(screen.getByText('Resource 1')).toBeInTheDocument();
expect(screen.getByText('Resource 5')).toBeInTheDocument();
});
it('shows/hides navigation arrows on hover', async () => {
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
const carouselContainer = container.querySelector('.relative.mb-4.pt-3');
// Initially arrows should be hidden (opacity-0)
const leftArrow = screen.queryByLabelText('Scroll left');
const rightArrow = screen.queryByLabelText('Scroll right');
// Right arrow should exist but left should not (at start)
expect(leftArrow).not.toBeInTheDocument();
expect(rightArrow).toBeInTheDocument();
expect(rightArrow).toHaveClass('opacity-0');
// Hover over container
fireEvent.mouseEnter(carouselContainer!);
await waitFor(() => {
expect(rightArrow).toHaveClass('opacity-100');
});
// Leave hover
fireEvent.mouseLeave(carouselContainer!);
await waitFor(() => {
expect(rightArrow).toHaveClass('opacity-0');
});
});
it('handles scroll navigation', async () => {
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
const scrollContainer = container.querySelector('.hide-scrollbar');
// Simulate being scrolled to show left arrow
Object.defineProperty(scrollContainer, 'scrollLeft', {
configurable: true,
value: 200,
});
// Trigger scroll event
fireEvent.scroll(scrollContainer!);
// Both arrows should now be visible
await waitFor(() => {
expect(screen.getByLabelText('Scroll left')).toBeInTheDocument();
expect(screen.getByLabelText('Scroll right')).toBeInTheDocument();
});
// Hover to make arrows interactive
const carouselContainer = container.querySelector('.relative.mb-4.pt-3');
fireEvent.mouseEnter(carouselContainer!);
// Click right arrow
fireEvent.click(screen.getByLabelText('Scroll right'));
expect(mockScrollTo).toHaveBeenCalledWith({
left: 650, // 200 + (500 * 0.9)
behavior: 'smooth',
});
// Click left arrow
fireEvent.click(screen.getByLabelText('Scroll left'));
expect(mockScrollTo).toHaveBeenCalledWith({
left: -250, // 200 - (500 * 0.9)
behavior: 'smooth',
});
});
it('hides right arrow when scrolled to end', async () => {
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
const scrollContainer = container.querySelector('.hide-scrollbar');
// Simulate scrolled to end
Object.defineProperty(scrollContainer, 'scrollLeft', {
configurable: true,
value: 490, // scrollWidth - clientWidth - 10
});
fireEvent.scroll(scrollContainer!);
await waitFor(() => {
expect(screen.getByLabelText('Scroll left')).toBeInTheDocument();
expect(screen.queryByLabelText('Scroll right')).not.toBeInTheDocument();
});
});
it('handles UIResource actions', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />);
const renderer = screen.getByTestId('ui-resource-renderer');
fireEvent.click(renderer);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Action:', { action: 'test' });
});
consoleSpy.mockRestore();
});
it('applies correct dimensions to resource containers', () => {
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 2)} />);
const containers = screen
.getAllByTestId('ui-resource-renderer')
.map((el) => el.parentElement?.parentElement);
containers.forEach((container, index) => {
expect(container).toHaveStyle({
width: '230px',
minHeight: '360px',
animationDelay: `${index * 100}ms`,
});
});
});
it('shows correct gradient overlays based on scroll position', () => {
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
// At start, left gradient should be hidden, right should be visible
const leftGradient = container.querySelector('.bg-gradient-to-r');
const rightGradient = container.querySelector('.bg-gradient-to-l');
expect(leftGradient).toHaveClass('opacity-0');
expect(rightGradient).toHaveClass('opacity-100');
});
it('cleans up event listeners on unmount', () => {
const { container, unmount } = render(<UIResourceCarousel uiResources={mockUIResources} />);
const scrollContainer = container.querySelector('.hide-scrollbar');
const removeEventListenerSpy = jest.spyOn(scrollContainer!, 'removeEventListener');
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
});
it('renders with animation delays for each resource', () => {
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 3)} />);
const resourceContainers = screen
.getAllByTestId('ui-resource-renderer')
.map((el) => el.parentElement?.parentElement);
resourceContainers.forEach((container, index) => {
expect(container).toHaveStyle({
animationDelay: `${index * 100}ms`,
});
});
});
it('memoizes component properly', () => {
const { rerender } = render(<UIResourceCarousel uiResources={mockUIResources} />);
const firstRender = screen.getAllByTestId('ui-resource-renderer');
// Re-render with same props
rerender(<UIResourceCarousel uiResources={mockUIResources} />);
const secondRender = screen.getAllByTestId('ui-resource-renderer');
// Component should not re-render with same props (React.memo)
expect(firstRender.length).toBe(secondRender.length);
});
});