mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-25 03:44:09 +01:00
* ✨ feat: Enhance Artifact Management with Version Control and UI Improvements ✨ feat: Improve mobile layout and responsiveness in Artifacts component ✨ feat: Refactor imports and remove unnecessary props in Artifact components ✨ feat: Enhance Artifacts and SidePanel components with improved mobile responsiveness and layout transitions feat: Enhance artifact panel animations and improve UI responsiveness - Updated Thinking component button styles for smoother transitions. - Implemented dynamic rendering for artifacts panel with animation effects. - Refactored localization keys for consistency across multiple languages. - Added new CSS animations for iOS-inspired smooth transitions. - Improved Tailwind CSS configuration to support enhanced animation effects. ✨ feat: Add fullWidth and icon support to Radio component for enhanced flexibility refactor: Remove unused PreviewProps import in ArtifactPreview component refactor: Improve button class handling and blur effect constants in Artifact components ✨ feat: Refactor Artifacts component structure and add mobile/desktop variants for improved UI chore: Bump @librechat/client version to 0.3.2 refactor: Update button styles and transition durations for improved UI responsiveness refactor: revert back localization key refactor: remove unused scaling and animation properties for cleaner CSS refactor: remove unused animation properties for cleaner configuration * ✨ refactor: Simplify className usage in ArtifactTabs, ArtifactsHeader, and SidePanelGroup components * refactor: Remove cycleArtifact function from useArtifacts hook * ✨ feat: Implement Chromium resize lag fix with performance optimizations and new ArtifactsPanel component * ✨ feat: Update Badge component for responsive design and improve tap scaling behavior * chore: Update react-resizable-panels dependency to version 3.0.6 * ✨ feat: Refactor Artifacts components for improved structure and performance; remove unused files and optimize styles * ✨ style: Update text color for improved visibility in Artifacts component * ✨ style: Remove text color class for improved Spinner styling in Artifacts component * refactor: Split EditorContext into MutationContext and CodeContext to optimize re-renders; update related components to use new hooks * refactor: Optimize debounced mutation handling in CodeEditor component using refs to maintain current values and reduce re-renders * fix: Correct endpoint for message artifacts by changing URL segment from 'artifacts' to 'artifact' * feat: Enhance useEditArtifact mutation with optimistic updates and rollback on error; improve type safety with context management * fix: proper switch to preview as soon as artifact becomes enclosed * refactor: Remove optimistic updates from useEditArtifact mutation to prevent errors; simplify onMutate logic * test: Add comprehensive unit tests for useArtifacts hook to validate artifact handling, tab switching, and state management * test: Enhance unit tests for useArtifacts hook to cover new conversation transitions and null message handling --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
693 lines
22 KiB
TypeScript
693 lines
22 KiB
TypeScript
import { renderHook, act } from '@testing-library/react';
|
|
import { Constants } from 'librechat-data-provider';
|
|
import type { Artifact } from '~/common';
|
|
|
|
/** Mock dependencies */
|
|
jest.mock('~/Providers', () => ({
|
|
useArtifactsContext: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/utils', () => ({
|
|
logger: {
|
|
log: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
/** Mock store before importing */
|
|
jest.mock('~/store', () => ({
|
|
__esModule: true,
|
|
default: {
|
|
artifactsState: { key: 'artifactsState' },
|
|
currentArtifactId: { key: 'currentArtifactId' },
|
|
artifactsVisibility: { key: 'artifactsVisibility' },
|
|
},
|
|
}));
|
|
|
|
jest.mock('recoil', () => {
|
|
const actualRecoil = jest.requireActual('recoil');
|
|
return {
|
|
...actualRecoil,
|
|
useRecoilValue: jest.fn(),
|
|
useRecoilState: jest.fn(),
|
|
useResetRecoilState: jest.fn(),
|
|
};
|
|
});
|
|
|
|
/** Import mocked functions after mocking */
|
|
import { useArtifactsContext } from '~/Providers';
|
|
import { useRecoilValue, useRecoilState, useResetRecoilState } from 'recoil';
|
|
import { logger } from '~/utils';
|
|
import useArtifacts from '../useArtifacts';
|
|
|
|
describe('useArtifacts', () => {
|
|
const mockResetArtifacts = jest.fn();
|
|
const mockResetCurrentArtifactId = jest.fn();
|
|
const mockSetCurrentArtifactId = jest.fn();
|
|
|
|
const createArtifact = (partial: Partial<Artifact>): Artifact => ({
|
|
id: 'artifact-1',
|
|
title: 'Test Artifact',
|
|
type: 'application/vnd.react',
|
|
content: 'const App = () => <div>Test</div>',
|
|
messageId: 'msg-1',
|
|
lastUpdateTime: Date.now(),
|
|
...partial,
|
|
});
|
|
|
|
const defaultContext = {
|
|
isSubmitting: false,
|
|
latestMessageId: 'msg-1',
|
|
latestMessageText: '',
|
|
conversationId: 'conv-1',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
jest.useFakeTimers();
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue(defaultContext);
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
|
(useResetRecoilState as jest.Mock).mockImplementation((atom) => {
|
|
if (atom?.key === 'artifactsState') {
|
|
return mockResetArtifacts;
|
|
}
|
|
if (atom?.key === 'currentArtifactId') {
|
|
return mockResetCurrentArtifactId;
|
|
}
|
|
return jest.fn();
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
describe('initial state', () => {
|
|
it('should initialize with preview tab active', () => {
|
|
const { result } = renderHook(() => useArtifacts());
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
|
|
it('should return null currentArtifact when no artifacts exist', () => {
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
const { result } = renderHook(() => useArtifacts());
|
|
expect(result.current.currentArtifact).toBeNull();
|
|
});
|
|
|
|
it('should return empty orderedArtifactIds when no artifacts exist', () => {
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
const { result } = renderHook(() => useArtifacts());
|
|
expect(result.current.orderedArtifactIds).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('artifact ordering', () => {
|
|
it('should order artifacts by lastUpdateTime', () => {
|
|
const artifacts = {
|
|
'artifact-3': createArtifact({ id: 'artifact-3', lastUpdateTime: 3000 }),
|
|
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
|
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
|
};
|
|
|
|
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.orderedArtifactIds).toEqual(['artifact-1', 'artifact-2', 'artifact-3']);
|
|
});
|
|
|
|
it('should automatically select latest artifact', () => {
|
|
const artifacts = {
|
|
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
|
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
|
};
|
|
|
|
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
|
|
|
renderHook(() => useArtifacts());
|
|
|
|
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-2');
|
|
});
|
|
});
|
|
|
|
describe('tab switching - enclosed artifacts', () => {
|
|
it('should switch to preview when enclosed artifact is detected during generation', () => {
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: false,
|
|
latestMessageText: '',
|
|
});
|
|
|
|
const { result, rerender } = renderHook(() => useArtifacts());
|
|
|
|
/** Generation starts with enclosed artifact */
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{title="Test"}\nconst App = () => <div>Test</div>\n:::',
|
|
});
|
|
|
|
rerender();
|
|
|
|
/** Should switch to preview when enclosed detected */
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
|
|
it('should not switch to preview if artifact is not enclosed', () => {
|
|
const artifact = createArtifact({
|
|
content: 'const App = () => <div>Test</div>',
|
|
});
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
|
|
|
const { result, rerender } = renderHook(() => useArtifacts());
|
|
|
|
/** Update with non-enclosed artifact */
|
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{title="Test"}\nconst App = () => <div>Test</div>',
|
|
});
|
|
|
|
rerender();
|
|
|
|
/** Should switch to code since artifact content is in message and not enclosed */
|
|
expect(result.current.activeTab).toBe('code');
|
|
expect(logger.log).not.toHaveBeenCalledWith(
|
|
'artifacts',
|
|
expect.stringContaining('Enclosed artifact'),
|
|
);
|
|
});
|
|
|
|
it('should only switch to preview once per artifact', () => {
|
|
const artifact = createArtifact({});
|
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
|
|
|
const { rerender } = renderHook(() => useArtifacts());
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{title="Test"}\ncode\n:::',
|
|
});
|
|
|
|
rerender();
|
|
|
|
const firstCallCount = (logger.log as jest.Mock).mock.calls.filter((call) =>
|
|
call[1]?.includes('Enclosed artifact'),
|
|
).length;
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{title="Test"}\ncode\n:::\nMore text',
|
|
});
|
|
|
|
rerender();
|
|
|
|
const secondCallCount = (logger.log as jest.Mock).mock.calls.filter((call) =>
|
|
call[1]?.includes('Enclosed artifact'),
|
|
).length;
|
|
|
|
expect(secondCallCount).toBe(firstCallCount);
|
|
});
|
|
});
|
|
|
|
describe('tab switching - non-enclosed artifacts', () => {
|
|
it('should switch to code when non-enclosed artifact content appears', () => {
|
|
const artifact = createArtifact({
|
|
content: 'const App = () => <div>Test Component</div>',
|
|
});
|
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: 'Here is the code: const App = () => <div>Test Component</div>',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('code');
|
|
});
|
|
|
|
it('should not switch to code if artifact content is not in message text', () => {
|
|
const artifact = createArtifact({
|
|
content: 'const App = () => <div>Test</div>',
|
|
});
|
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: 'Some other text here',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
});
|
|
|
|
describe('conversation changes', () => {
|
|
it('should reset artifacts when conversation changes', () => {
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
|
|
const { rerender } = renderHook(() => useArtifacts());
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
conversationId: 'conv-2',
|
|
});
|
|
|
|
rerender();
|
|
|
|
expect(mockResetArtifacts).toHaveBeenCalled();
|
|
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reset artifacts when navigating to new conversation from another conversation', () => {
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
|
|
/** Start with existing conversation (NOT Constants.NEW_CONVO) */
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
conversationId: 'existing-conv',
|
|
});
|
|
|
|
const { rerender } = renderHook(() => useArtifacts());
|
|
|
|
jest.clearAllMocks();
|
|
|
|
/** Navigate to NEW_CONVO - this should trigger the else if branch */
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
conversationId: Constants.NEW_CONVO,
|
|
});
|
|
|
|
rerender();
|
|
|
|
expect(mockResetArtifacts).toHaveBeenCalled();
|
|
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not reset artifacts on initial render', () => {
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
renderHook(() => useArtifacts());
|
|
|
|
expect(mockResetArtifacts).not.toHaveBeenCalled();
|
|
expect(mockResetCurrentArtifactId).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reset when transitioning from null to NEW_CONVO', () => {
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
|
|
/** Start with null conversationId */
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
conversationId: null,
|
|
});
|
|
|
|
const { rerender } = renderHook(() => useArtifacts());
|
|
|
|
jest.clearAllMocks();
|
|
|
|
/** Transition to NEW_CONVO - triggers the else if branch (line 44) */
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
conversationId: Constants.NEW_CONVO,
|
|
});
|
|
|
|
rerender();
|
|
|
|
/** Should reset because we're now on NEW_CONVO */
|
|
expect(mockResetArtifacts).toHaveBeenCalled();
|
|
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reset state flags when message ID changes', () => {
|
|
const artifact = createArtifact({});
|
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{}\ncode\n:::',
|
|
latestMessageId: 'msg-1',
|
|
});
|
|
|
|
const { result, rerender } = renderHook(() => useArtifacts());
|
|
|
|
// First artifact becomes enclosed
|
|
expect(result.current.activeTab).toBe('preview');
|
|
|
|
// New message starts
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: 'New message',
|
|
latestMessageId: 'msg-2',
|
|
});
|
|
|
|
rerender();
|
|
|
|
// Should allow switching again for the new message
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{}\nnew code\n:::',
|
|
latestMessageId: 'msg-2',
|
|
});
|
|
|
|
rerender();
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
});
|
|
|
|
describe('cleanup on unmount', () => {
|
|
it('should reset artifacts when unmounting', () => {
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
|
|
const { unmount } = renderHook(() => useArtifacts());
|
|
|
|
unmount();
|
|
|
|
expect(mockResetArtifacts).toHaveBeenCalled();
|
|
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
|
expect(logger.log).toHaveBeenCalledWith('artifacts_visibility', 'Unmounting artifacts');
|
|
});
|
|
});
|
|
|
|
describe('manual tab switching', () => {
|
|
it('should allow manually switching tabs', () => {
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
|
|
act(() => {
|
|
result.current.setActiveTab('code');
|
|
});
|
|
|
|
expect(result.current.activeTab).toBe('code');
|
|
});
|
|
|
|
it('should allow switching back to preview after manual switch to code', () => {
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
act(() => {
|
|
result.current.setActiveTab('code');
|
|
});
|
|
|
|
expect(result.current.activeTab).toBe('code');
|
|
|
|
act(() => {
|
|
result.current.setActiveTab('preview');
|
|
});
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
});
|
|
|
|
describe('currentIndex calculation', () => {
|
|
it('should return correct index for current artifact', () => {
|
|
const artifacts = {
|
|
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
|
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
|
'artifact-3': createArtifact({ id: 'artifact-3', lastUpdateTime: 3000 }),
|
|
};
|
|
|
|
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-2', mockSetCurrentArtifactId]);
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.currentIndex).toBe(1);
|
|
});
|
|
|
|
it('should return -1 for non-existent artifact', () => {
|
|
const artifacts = {
|
|
'artifact-1': createArtifact({ id: 'artifact-1' }),
|
|
};
|
|
|
|
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
|
(useRecoilState as jest.Mock).mockReturnValue(['non-existent', mockSetCurrentArtifactId]);
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.currentIndex).toBe(-1);
|
|
});
|
|
});
|
|
|
|
describe('complex scenarios', () => {
|
|
it('should detect and handle enclosed artifacts during generation', async () => {
|
|
/** Start fresh with enclosed artifact already present */
|
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
|
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{title="Component"}\nconst App = () => <div>Test</div>\n:::',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
/** Should detect enclosed pattern and be on preview */
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
|
|
it('should handle multiple artifacts in sequence', () => {
|
|
const artifact1 = createArtifact({ id: 'artifact-1', messageId: 'msg-1' });
|
|
const artifact2 = createArtifact({ id: 'artifact-2', messageId: 'msg-2' });
|
|
|
|
/** First artifact */
|
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact1 });
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{}\ncode1\n:::',
|
|
latestMessageId: 'msg-1',
|
|
});
|
|
|
|
const { result, rerender } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
|
|
/** Second artifact starts (new message) */
|
|
(useRecoilValue as jest.Mock).mockReturnValue({
|
|
'artifact-1': artifact1,
|
|
'artifact-2': artifact2,
|
|
});
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: 'Here is another one',
|
|
latestMessageId: 'msg-2',
|
|
});
|
|
|
|
rerender();
|
|
|
|
/** Second artifact becomes enclosed */
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{}\ncode2\n:::',
|
|
latestMessageId: 'msg-2',
|
|
});
|
|
|
|
rerender();
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle null artifacts gracefully', () => {
|
|
(useRecoilValue as jest.Mock).mockReturnValue(null);
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.orderedArtifactIds).toEqual([]);
|
|
expect(result.current.currentArtifact).toBeNull();
|
|
});
|
|
|
|
it('should handle undefined artifacts gracefully', () => {
|
|
(useRecoilValue as jest.Mock).mockReturnValue(undefined);
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.orderedArtifactIds).toEqual([]);
|
|
expect(result.current.currentArtifact).toBeNull();
|
|
});
|
|
|
|
it('should handle empty latestMessageText', () => {
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: '',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
|
|
it('should handle malformed artifact syntax', () => {
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact\ncode but no closing',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
|
|
it('should handle artifact with only opening tag', () => {
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{title="Test"}',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
});
|
|
|
|
describe('artifact content comparison', () => {
|
|
it('should not switch tabs when artifact content does not change', () => {
|
|
const artifact = createArtifact({});
|
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: 'Some text',
|
|
});
|
|
|
|
const { result, rerender } = renderHook(() => useArtifacts());
|
|
|
|
const initialTab = result.current.activeTab;
|
|
|
|
/** Same content, just rerender */
|
|
rerender();
|
|
|
|
expect(result.current.activeTab).toBe(initialTab);
|
|
});
|
|
});
|
|
|
|
describe('isSubmitting state handling', () => {
|
|
it('should process when isSubmitting is true', () => {
|
|
const artifact = createArtifact({});
|
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{}\ncode\n:::',
|
|
});
|
|
|
|
renderHook(() => useArtifacts());
|
|
|
|
expect(mockSetCurrentArtifactId).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should still select latest artifact even when idle (via orderedArtifactIds effect)', () => {
|
|
const artifact = createArtifact({});
|
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
|
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: false,
|
|
latestMessageText: 'Some text',
|
|
});
|
|
|
|
renderHook(() => useArtifacts());
|
|
|
|
/** The orderedArtifactIds effect always runs when artifacts change */
|
|
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-1');
|
|
});
|
|
|
|
it('should not process when latestMessageId is null', () => {
|
|
const artifact = createArtifact({});
|
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
|
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
|
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageId: null,
|
|
latestMessageText: ':::artifact{}\ncode\n:::',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
/** Main effect should exit early and not switch tabs */
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
});
|
|
|
|
describe('regex pattern matching', () => {
|
|
it('should match artifact with title attribute', () => {
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{title="My Component"}\ncode\n:::',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
|
|
it('should match artifact with multiple attributes', () => {
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{title="Test" type="react" identifier="comp-1"}\ncode\n:::',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
|
|
it('should match artifact with code blocks inside', () => {
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{}\n```typescript\nconst x = 1;\n```\n:::',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
|
|
it('should match artifact with whitespace variations', () => {
|
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
|
...defaultContext,
|
|
isSubmitting: true,
|
|
latestMessageText: ':::artifact{title="Test"} \n\n code here \n\n :::',
|
|
});
|
|
|
|
const { result } = renderHook(() => useArtifacts());
|
|
|
|
expect(result.current.activeTab).toBe('preview');
|
|
});
|
|
});
|
|
});
|