💻 feat: deeper MCP UI integration in the chat UI using plugins

---------

Co-authored-by: Samuel Path <samuel.path@shopify.com>
Co-authored-by: Pierre-Luc Godin <pierreluc.godin@shopify.com>
This commit is contained in:
Pierre-Luc Godin 2025-10-16 14:51:01 -04:00 committed by Dustin Healy
parent b8a149e563
commit 08103ffb22
22 changed files with 1441 additions and 54 deletions

View file

@ -0,0 +1,259 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MCPUIResource } from '../MCPUIResource';
import { useMessageContext, useChatContext } from '~/Providers';
import { useGetMessagesByConvoId } from '~/data-provider';
import { useLocalize } from '~/hooks';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
import { handleUIAction } from '~/utils';
// Mock dependencies
jest.mock('~/Providers');
jest.mock('~/data-provider');
jest.mock('~/hooks');
jest.mock('~/hooks/Messages/useSubmitMessage');
jest.mock('~/utils');
jest.mock('@mcp-ui/client', () => ({
UIResourceRenderer: ({ resource, onUIAction }: any) => (
<div
data-testid="ui-resource-renderer"
data-resource-uri={resource?.uri}
onClick={() => onUIAction({ action: 'test' })}
/>
),
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
typeof useGetMessagesByConvoId
>;
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>;
const mockUseSubmitMessage = useSubmitMessage as jest.MockedFunction<typeof useSubmitMessage>;
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;
describe('MCPUIResource', () => {
const mockLocalize = (key: string, values?: any) => {
const translations: Record<string, string> = {
com_ui_ui_resource_not_found: `UI resource at index ${values?.[0]} not found`,
com_ui_ui_resource_error: `Error rendering UI resource: ${values?.[0]}`,
};
return translations[key] || key;
};
const mockSubmitMessageFn = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any);
mockUseChatContext.mockReturnValue({
conversation: { conversationId: 'conv123' },
} as any);
mockUseLocalize.mockReturnValue(mockLocalize as any);
mockUseSubmitMessage.mockReturnValue({ submitMessage: mockSubmitMessageFn } as any);
});
describe('resource fetching', () => {
it('should fetch and render UI resource from message attachments', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{ uri: 'ui://test/resource', mimeType: 'text/html', text: '<p>Test Resource</p>' },
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
const renderer = screen.getByTestId('ui-resource-renderer');
expect(renderer).toBeInTheDocument();
expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource');
});
it('should show not found message when resource index is out of bounds', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{ uri: 'ui://test/resource', mimeType: 'text/html', text: '<p>Test Resource</p>' },
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 5 } }} />);
expect(screen.getByText('UI resource at index 5 not found')).toBeInTheDocument();
expect(screen.queryByTestId('ui-resource-renderer')).not.toBeInTheDocument();
});
it('should show not found when target message is not found', () => {
const mockMessages = [
{
messageId: 'different-msg',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{ uri: 'ui://test/resource', mimeType: 'text/html', text: '<p>Test Resource</p>' },
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
});
it('should show not found when no ui_resources attachments', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'web_search',
web_search: { results: [] },
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
});
});
describe('UI action handling', () => {
it('should handle UI actions with handleUIAction', async () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
uri: 'ui://test/resource',
mimeType: 'text/html',
text: '<p>Interactive Resource</p>',
},
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
const renderer = screen.getByTestId('ui-resource-renderer');
renderer.click();
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessageFn);
});
});
describe('edge cases', () => {
it('should handle empty messages array', () => {
mockUseGetMessagesByConvoId.mockReturnValue({
data: [],
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
});
it('should handle null messages data', () => {
mockUseGetMessagesByConvoId.mockReturnValue({
data: null,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
});
it('should handle missing conversation', () => {
mockUseChatContext.mockReturnValue({
conversation: null,
} as any);
mockUseGetMessagesByConvoId.mockReturnValue({
data: null,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
});
it('should handle multiple attachments of ui_resources type', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{ uri: 'ui://test/resource1', mimeType: 'text/html', text: '<p>Resource 1</p>' },
],
},
{
type: 'ui_resources',
ui_resources: [
{ uri: 'ui://test/resource2', mimeType: 'text/html', text: '<p>Resource 2</p>' },
{ uri: 'ui://test/resource3', mimeType: 'text/html', text: '<p>Resource 3</p>' },
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
// With global indexing across attachments, index 1 should pick the second overall resource
// Flattened order: [resource1, resource2, resource3]
render(<MCPUIResource node={{ properties: { resourceIndex: 1 } }} />);
const renderer = screen.getByTestId('ui-resource-renderer');
expect(renderer).toBeInTheDocument();
expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource2');
});
});
});

View file

@ -0,0 +1,454 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MCPUIResourceCarousel } from '../MCPUIResourceCarousel';
import { useMessageContext, useChatContext } from '~/Providers';
import { useGetMessagesByConvoId } from '~/data-provider';
// Mock dependencies
jest.mock('~/Providers');
jest.mock('~/data-provider');
jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({
__esModule: true,
default: ({ uiResources }: any) => (
<div data-testid="ui-resource-carousel" data-resource-count={uiResources.length}>
{uiResources.map((resource: any, index: number) => (
<div key={index} data-testid={`resource-${index}`} data-resource-uri={resource.uri} />
))}
</div>
),
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
typeof useGetMessagesByConvoId
>;
describe('MCPUIResourceCarousel', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any);
mockUseChatContext.mockReturnValue({
conversation: { conversationId: 'conv123' },
} as any);
});
describe('multiple resource fetching', () => {
it('should fetch multiple resources by indices', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
uri: 'ui://test/resource0',
mimeType: 'text/html',
text: '<p>Resource 0 content</p>',
},
{
uri: 'ui://test/resource1',
mimeType: 'text/html',
text: '<p>Resource 1 content</p>',
},
{
uri: 'ui://test/resource2',
mimeType: 'text/html',
text: '<p>Resource 2 content</p>',
},
{
uri: 'ui://test/resource3',
mimeType: 'text/html',
text: '<p>Resource 3 content</p>',
},
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 2, 3] } }} />);
const carousel = screen.getByTestId('ui-resource-carousel');
expect(carousel).toBeInTheDocument();
expect(carousel).toHaveAttribute('data-resource-count', '3');
expect(screen.getByTestId('resource-0')).toHaveAttribute(
'data-resource-uri',
'ui://test/resource0',
);
expect(screen.getByTestId('resource-1')).toHaveAttribute(
'data-resource-uri',
'ui://test/resource2',
);
expect(screen.getByTestId('resource-2')).toHaveAttribute(
'data-resource-uri',
'ui://test/resource3',
);
expect(screen.queryByTestId('resource-3')).not.toBeInTheDocument();
});
it('should preserve resource order based on indices', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
uri: 'ui://test/resource0',
mimeType: 'text/html',
text: '<p>Resource 0 content</p>',
},
{
uri: 'ui://test/resource1',
mimeType: 'text/html',
text: '<p>Resource 1 content</p>',
},
{
uri: 'ui://test/resource2',
mimeType: 'text/html',
text: '<p>Resource 2 content</p>',
},
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [2, 0, 1] } }} />);
const resources = screen.getAllByTestId(/resource-\d/);
expect(resources[0]).toHaveAttribute('data-resource-uri', 'ui://test/resource2');
expect(resources[1]).toHaveAttribute('data-resource-uri', 'ui://test/resource0');
expect(resources[2]).toHaveAttribute('data-resource-uri', 'ui://test/resource1');
});
});
describe('partial matches', () => {
it('should only include valid resource indices', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
uri: 'ui://test/resource0',
mimeType: 'text/html',
text: '<p>Resource 0 content</p>',
},
{
uri: 'ui://test/resource1',
mimeType: 'text/html',
text: '<p>Resource 1 content</p>',
},
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
// Request indices 0, 1, 2, 3 but only 0 and 1 exist
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 1, 2, 3] } }} />);
const carousel = screen.getByTestId('ui-resource-carousel');
expect(carousel).toHaveAttribute('data-resource-count', '2');
expect(screen.getByTestId('resource-0')).toHaveAttribute(
'data-resource-uri',
'ui://test/resource0',
);
expect(screen.getByTestId('resource-1')).toHaveAttribute(
'data-resource-uri',
'ui://test/resource1',
);
});
it('should handle all invalid indices', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
uri: 'ui://test/resource0',
mimeType: 'text/html',
text: '<p>Resource 0 content</p>',
},
{
uri: 'ui://test/resource1',
mimeType: 'text/html',
text: '<p>Resource 1 content</p>',
},
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
// Request indices that don't exist
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [5, 6, 7] } }} />);
expect(screen.queryByTestId('ui-resource-carousel')).not.toBeInTheDocument();
});
});
describe('error handling', () => {
it('should return null when no attachments', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: undefined,
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 1] } }} />,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByTestId('ui-resource-carousel')).not.toBeInTheDocument();
});
it('should return null when message not found', () => {
const mockMessages = [
{
messageId: 'different-msg',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
uri: 'ui://test/resource',
mimeType: 'text/html',
text: '<p>Resource content</p>',
},
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0] } }} />,
);
expect(container.firstChild).toBeNull();
});
it('should return null when no ui_resources attachments', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'web_search',
web_search: { results: [] },
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 1] } }} />,
);
expect(container.firstChild).toBeNull();
});
});
describe('edge cases', () => {
it('should handle empty resourceIndices array', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
uri: 'ui://test/resource',
mimeType: 'text/html',
text: '<p>Resource content</p>',
},
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [] } }} />,
);
expect(container.firstChild).toBeNull();
});
it('should handle duplicate indices', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
uri: 'ui://test/resource0',
mimeType: 'text/html',
text: '<p>Resource 0 content</p>',
},
{
uri: 'ui://test/resource1',
mimeType: 'text/html',
text: '<p>Resource 1 content</p>',
},
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
// Request same index multiple times
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 0, 1, 1, 0] } }} />);
// Should render each resource multiple times
const carousel = screen.getByTestId('ui-resource-carousel');
expect(carousel).toHaveAttribute('data-resource-count', '5');
const resources = screen.getAllByTestId(/resource-\d/);
expect(resources).toHaveLength(5);
expect(resources[0]).toHaveAttribute('data-resource-uri', 'ui://test/resource0');
expect(resources[1]).toHaveAttribute('data-resource-uri', 'ui://test/resource0');
expect(resources[2]).toHaveAttribute('data-resource-uri', 'ui://test/resource1');
expect(resources[3]).toHaveAttribute('data-resource-uri', 'ui://test/resource1');
expect(resources[4]).toHaveAttribute('data-resource-uri', 'ui://test/resource0');
});
it('should handle multiple ui_resources attachments', () => {
const mockMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
uri: 'ui://test/resource0',
mimeType: 'text/html',
text: '<p>Resource 0 content</p>',
},
{
uri: 'ui://test/resource1',
mimeType: 'text/html',
text: '<p>Resource 1 content</p>',
},
],
},
{
type: 'ui_resources',
ui_resources: [
{ uri: 'ui://test/resource2', mimeType: 'text/html', text: '<p>Resource 2</p>' },
{ uri: 'ui://test/resource3', mimeType: 'text/html', text: '<p>Resource 3</p>' },
],
},
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({
data: mockMessages,
} as any);
// Resources from both attachments should be accessible
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 2, 3] } }} />);
const carousel = screen.getByTestId('ui-resource-carousel');
expect(carousel).toHaveAttribute('data-resource-count', '3');
// Note: indices 2 and 3 are from the second attachment and become accessible in the flattened array
expect(screen.getByTestId('resource-0')).toHaveAttribute(
'data-resource-uri',
'ui://test/resource0',
);
expect(screen.getByTestId('resource-1')).toHaveAttribute(
'data-resource-uri',
'ui://test/resource2',
);
expect(screen.getByTestId('resource-2')).toHaveAttribute(
'data-resource-uri',
'ui://test/resource3',
);
});
it('should handle null messages data', () => {
mockUseGetMessagesByConvoId.mockReturnValue({
data: null,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0] } }} />,
);
expect(container.firstChild).toBeNull();
});
it('should handle missing conversation', () => {
mockUseChatContext.mockReturnValue({
conversation: null,
} as any);
mockUseGetMessagesByConvoId.mockReturnValue({
data: null,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0] } }} />,
);
expect(container.firstChild).toBeNull();
});
});
});

View file

@ -0,0 +1,249 @@
import { mcpUIResourcePlugin, UI_RESOURCE_MARKER } from '../plugin';
import type { Node } from 'unist';
import type { UIResourceNode } from '../types';
describe('mcpUIResourcePlugin', () => {
const createTextNode = (value: string): UIResourceNode => ({
type: 'text',
value,
});
const createTree = (nodes: UIResourceNode[]): Node =>
({
type: 'root',
children: nodes,
}) as Node;
const processTree = (tree: Node) => {
const plugin = mcpUIResourcePlugin();
plugin(tree);
return tree;
};
describe('single resource markers', () => {
it('should replace single UI resource marker with mcp-ui-resource node', () => {
const tree = createTree([createTextNode(`Here is a resource ${UI_RESOURCE_MARKER}0`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0]).toEqual({ type: 'text', value: 'Here is a resource ' });
expect(children[1]).toEqual({
type: 'mcp-ui-resource',
data: {
hName: 'mcp-ui-resource',
hProperties: {
resourceIndex: 0,
},
},
});
});
it('should handle multiple single resource markers', () => {
const tree = createTree([
createTextNode(`First ${UI_RESOURCE_MARKER}0 and second ${UI_RESOURCE_MARKER}1`),
]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(4);
expect(children[0]).toEqual({ type: 'text', value: 'First ' });
expect(children[1].type).toBe('mcp-ui-resource');
expect(children[1].data.hProperties.resourceIndex).toBe(0);
expect(children[2]).toEqual({ type: 'text', value: ' and second ' });
expect(children[3].type).toBe('mcp-ui-resource');
expect(children[3].data.hProperties.resourceIndex).toBe(1);
});
it('should handle large index numbers', () => {
const tree = createTree([createTextNode(`Resource ${UI_RESOURCE_MARKER}42`)]);
processTree(tree);
const children = (tree as any).children;
expect(children[1].data.hProperties.resourceIndex).toBe(42);
});
});
describe('carousel markers', () => {
it('should replace carousel marker with mcp-ui-carousel node', () => {
const tree = createTree([createTextNode(`Carousel ${UI_RESOURCE_MARKER}0,1,2`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0]).toEqual({ type: 'text', value: 'Carousel ' });
expect(children[1]).toEqual({
type: 'mcp-ui-carousel',
data: {
hName: 'mcp-ui-carousel',
hProperties: {
resourceIndices: [0, 1, 2],
},
},
});
});
it('should handle large index numbers in carousel', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}100,200,300`)]);
processTree(tree);
const children = (tree as any).children;
expect(children[0].data.hProperties.resourceIndices).toEqual([100, 200, 300]);
});
});
describe('mixed content', () => {
it('should handle text before and after markers', () => {
const tree = createTree([
createTextNode(`Before ${UI_RESOURCE_MARKER}0 middle ${UI_RESOURCE_MARKER}1,2 after`),
]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(5);
expect(children[0].value).toBe('Before ');
expect(children[1].type).toBe('mcp-ui-resource');
expect(children[2].value).toBe(' middle ');
expect(children[3].type).toBe('mcp-ui-carousel');
expect(children[4].value).toBe(' after');
});
it('should handle marker at start of text', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}0 after`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0].type).toBe('mcp-ui-resource');
expect(children[1].value).toBe(' after');
});
it('should handle marker at end of text', () => {
const tree = createTree([createTextNode(`Before ${UI_RESOURCE_MARKER}0`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0].value).toBe('Before ');
expect(children[1].type).toBe('mcp-ui-resource');
});
it('should handle consecutive markers', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}0${UI_RESOURCE_MARKER}1`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0].type).toBe('mcp-ui-resource');
expect(children[0].data.hProperties.resourceIndex).toBe(0);
expect(children[1].type).toBe('mcp-ui-resource');
expect(children[1].data.hProperties.resourceIndex).toBe(1);
});
});
describe('edge cases', () => {
it('should handle empty text nodes', () => {
const tree = createTree([createTextNode('')]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0]).toEqual({ type: 'text', value: '' });
});
it('should handle text without markers', () => {
const tree = createTree([createTextNode('No markers here')]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0]).toEqual({ type: 'text', value: 'No markers here' });
});
it('should handle non-text nodes', () => {
const tree = createTree([{ type: 'paragraph', children: [] }]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('paragraph');
});
it('should handle nested structures', () => {
const tree = {
type: 'root',
children: [
{
type: 'paragraph',
children: [createTextNode(`Text with ${UI_RESOURCE_MARKER}0`)],
},
],
} as Node;
processTree(tree);
const paragraph = (tree as any).children[0];
const textNodes = paragraph.children;
expect(textNodes).toHaveLength(2);
expect(textNodes[0].value).toBe('Text with ');
expect(textNodes[1].type).toBe('mcp-ui-resource');
});
it('should not process nodes without value property', () => {
const tree = createTree([
{
type: 'text',
// no value property
} as UIResourceNode,
]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('text');
});
});
describe('pattern validation', () => {
it('should not match invalid patterns', () => {
const invalidPatterns = [
`${UI_RESOURCE_MARKER}`,
`${UI_RESOURCE_MARKER}abc`,
`${UI_RESOURCE_MARKER}-1`,
`${UI_RESOURCE_MARKER},1`,
`ui0`, // missing marker
];
invalidPatterns.forEach((pattern) => {
const tree = createTree([createTextNode(pattern)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('text');
expect(children[0].value).toBe(pattern);
});
});
it('should handle partial matches correctly', () => {
// Test that ui1.2 matches ui1 and leaves .2
const tree1 = createTree([createTextNode(`${UI_RESOURCE_MARKER}1.2`)]);
processTree(tree1);
const children1 = (tree1 as any).children;
expect(children1).toHaveLength(2);
expect(children1[0].type).toBe('mcp-ui-resource');
expect(children1[0].data.hProperties.resourceIndex).toBe(1);
expect(children1[1].value).toBe('.2');
// Test that ui1, matches as single resource followed by comma
const tree2 = createTree([createTextNode(`${UI_RESOURCE_MARKER}1,`)]);
processTree(tree2);
const children2 = (tree2 as any).children;
expect(children2).toHaveLength(2);
expect(children2[0].type).toBe('mcp-ui-resource');
expect(children2[0].data.hProperties.resourceIndex).toBe(1);
expect(children2[1].value).toBe(',');
});
});
});