Skip to content

Commit 17b027f

Browse files
committed
test: Storybook interaction/unit tests WIP
Creates Storybook stories for ChatForm, ChatMessage and ChatSidebar components to facilitate UI development and testing. Introduces mock data and server configurations for different scenarios, including vision and audio modalities. Adds stories to test various states such as loading, search activity, and file attachments.
1 parent cf408db commit 17b027f

11 files changed

+362
-14
lines changed

tools/server/webui/src/stories/ChatForm.stories.svelte

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script module>
22
import { defineMeta } from '@storybook/addon-svelte-csf';
33
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
4-
import { expect } from 'storybook/internal/test';
4+
import { expect, waitFor } from 'storybook/internal/test';
5+
import { mockServerProps, mockConfigs } from './fixtures/storybook-mocks';
56
67
const { Story } = defineMeta({
78
title: 'Components/ChatForm',
@@ -10,6 +11,42 @@
1011
layout: 'centered'
1112
}
1213
});
14+
15+
// Mock uploaded files with working data URLs for Storybook
16+
const mockFileAttachments = [
17+
// {
18+
// id: '1',
19+
// name: '1.jpg',
20+
// type: 'image/jpeg',
21+
// size: 44891,
22+
// url: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iIzMzNzNkYyIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LXNpemU9IjE4IiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPkltYWdlPC90ZXh0Pjwvc3ZnPg==',
23+
// file: new File([''], '1.jpg', { type: 'image/jpeg' })
24+
// },
25+
// {
26+
// id: '2',
27+
// name: 'beautiful-flowers-lotus.webp',
28+
// type: 'image/webp',
29+
// size: 817630,
30+
// url: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iIzMzNzNkYyIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LXNpemU9IjE4IiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPkZsb3dlcnM8L3RleHQ+PC9zdmc+',
31+
// file: new File([''], 'beautiful-flowers-lotus.webp', { type: 'image/webp' })
32+
// },
33+
// {
34+
// id: '3',
35+
// name: 'recording.wav',
36+
// type: 'audio/wav',
37+
// size: 512000,
38+
// url: 'data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIG2m98OScTgwOUarm7blmGgU7k9n1unEiBC13yO/eizEIHWq+8+OWT',
39+
// file: new File(['test audio content'], 'recording.wav', { type: 'audio/wav' })
40+
// },
41+
{
42+
id: '4',
43+
name: 'example.pdf',
44+
type: 'application/pdf',
45+
size: 351048,
46+
url: 'data:application/pdf;base64,JVBERi0xLjQKJcOkw7zDtsO4CjIgMCBvYmoKPDwKL0xlbmd0aCAzIDAgUgovRmlsdGVyIC9GbGF0ZURlY29kZQo+PgpzdHJlYW0KeJxLy8wpTVWwUshIzStRyE9VqFYoLU4tykvMTVUozy/KSVGwUsjNTFGwUsrIyFGwUsrJTFGyMjJQUKhWykvMTbVSqAUAXYsZGAplbmRzdHJlYW0KZW5kb2JqCgozIDAgb2JqCjw8Ci9MZW5ndGggNDcKPj4Kc3RyZWFtCkJUCi9GMSAxMiBUZgoxIDAgMCAxIDcwIDc1MCBUbQooSGVsbG8gV29ybGQpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKCjQgMCBvYmoKPDwKL1R5cGUgL0ZvbnQKL1N1YnR5cGUgL1R5cGUxCi9CYXNlRm9udCAvSGVsdmV0aWNhCj4+CmVuZG9iagoKNSAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDYgMCBSCi9SZXNvdXJjZXMgPDwKL0ZvbnQgPDwKL0YxIDQgMCBSCj4+Cj4+Ci9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyAzIDAgUgo+PgplbmRvYmoKCjYgMCBvYmoKPDwKL1R5cGUgL1BhZ2VzCi9LaWRzIFs1IDAgUl0KL0NvdW50IDEKL01lZGlhQm94IFswIDAgNjEyIDc5Ml0KPj4KZW5kb2JqCgo3IDAgb2JqCjw8Ci9UeXBlIC9DYXRhbG9nCi9QYWdlcyA2IDAgUgo+PgplbmRvYmoKCnhyZWYKMCA4CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDc0IDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDI3MyAwMDAwMCBuIAowMDAwMDAwMzQ4IDAwMDAwIG4gCjAwMDAwMDA0ODYgMDAwMDAgbiAKMDAwMDAwMDU2MyAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDgKL1Jvb3QgNyAwIFIKPj4Kc3RhcnR4cmVmCjYxMwolJUVPRgo=',
47+
file: new File(['%PDF-1.4 test content'], 'example.pdf', { type: 'application/pdf' })
48+
}
49+
];
1350
</script>
1451

1552
<Story
@@ -31,10 +68,107 @@
3168
await userEvent.type(textarea, text);
3269

3370
await expect(textarea).toHaveValue(text);
71+
72+
const fileInput = document.querySelector('input[type="file"]');
73+
const acceptAttr = fileInput?.getAttribute('accept');
74+
await expect(fileInput).toHaveAttribute('accept');
75+
await expect(acceptAttr).not.toContain('image/');
76+
await expect(acceptAttr).not.toContain('audio/');
77+
78+
79+
const fileUploadButton = canvas.getByText('Attach files');
80+
81+
await userEvent.click(fileUploadButton);
82+
83+
const recordButton = canvas.getAllByRole('button', { name: 'Start recording' })[1];
84+
const imagesButton = document.querySelector('.images-button');
85+
const audioButton = document.querySelector('.audio-button');
86+
87+
88+
await expect(recordButton).toBeDisabled();
89+
await expect(imagesButton).toHaveAttribute('data-disabled');
90+
await expect(audioButton).toHaveAttribute('data-disabled');
3491
}}
3592
/>
3693

3794
<Story
3895
name="Loading"
3996
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', isLoading: true }}
4097
/>
98+
99+
<Story
100+
name="VisionModality"
101+
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
102+
play={async ({ canvas, userEvent }) => {
103+
mockServerProps(mockConfigs.visionOnly);
104+
105+
await waitFor(() => {
106+
const fileInput = document.querySelector('input[type="file"]');
107+
const acceptAttr = fileInput?.getAttribute('accept');
108+
return acceptAttr;
109+
});
110+
111+
// Test initial file input state (should not accept images/audio without dropdown selection)
112+
const fileInput = document.querySelector('input[type="file"]');
113+
const acceptAttr = fileInput?.getAttribute('accept');
114+
console.log(acceptAttr);
115+
await expect(fileInput).toHaveAttribute('accept');
116+
await expect(acceptAttr).toContain('image/');
117+
await expect(acceptAttr).not.toContain('audio/');
118+
119+
const fileUploadButton = canvas.getByText('Attach files');
120+
await userEvent.click(fileUploadButton);
121+
122+
// Test that record button is disabled (no audio support)
123+
const recordButton = canvas.getAllByRole('button', { name: 'Start recording' })[1];
124+
await expect(recordButton).toBeDisabled();
125+
126+
// Test that Images button is enabled (vision support)
127+
const imagesButton = document.querySelector('.images-button');
128+
await expect(imagesButton).not.toHaveAttribute('data-disabled');
129+
130+
// Test that Audio button is disabled (no audio support)
131+
const audioButton = document.querySelector('.audio-button');
132+
await expect(audioButton).toHaveAttribute('data-disabled');
133+
134+
console.log('✅ Vision modality: Images enabled, Audio/Recording disabled');
135+
}}
136+
/>
137+
138+
139+
<Story
140+
name="FileAttachments"
141+
args={{
142+
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
143+
uploadedFiles: mockFileAttachments
144+
}}
145+
play={async ({ canvasElement }) => {
146+
// Test that both vision and audio modalities are enabled
147+
const fileUploadButton = canvasElement.querySelector('button[aria-label*="Upload"], button:has([data-lucide="paperclip"])');
148+
149+
if (fileUploadButton && fileUploadButton instanceof HTMLButtonElement) {
150+
fileUploadButton.click();
151+
152+
// Wait for dropdown to appear
153+
await new Promise(resolve => setTimeout(resolve, 100));
154+
155+
// Check if both Images and Audio options are available
156+
const imagesOption = canvasElement.querySelector('[data-testid="upload-images"], button:contains("Images")');
157+
const audioOption = canvasElement.querySelector('[data-testid="upload-audio"], button:contains("Audio")');
158+
159+
if (imagesOption && imagesOption instanceof HTMLButtonElement && !imagesOption.disabled) {
160+
console.log('✅ File Attachments: Vision modality enabled');
161+
}
162+
163+
if (audioOption && audioOption instanceof HTMLButtonElement && !audioOption.disabled) {
164+
console.log('✅ File Attachments: Audio modality enabled');
165+
}
166+
}
167+
168+
// Test microphone availability
169+
const micButton = canvasElement.querySelector('button[aria-label*="Record"], button:has([data-lucide="mic"])');
170+
if (micButton && micButton instanceof HTMLButtonElement && !micButton.disabled) {
171+
console.log('✅ File Attachments: Microphone recording enabled');
172+
}
173+
}}
174+
/>
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<script module lang="ts">
2+
import { defineMeta } from '@storybook/addon-svelte-csf';
3+
import ChatMessage from '$lib/components/app/chat/ChatMessages/ChatMessage.svelte';
4+
5+
const { Story } = defineMeta({
6+
title: 'Components/ChatMessage',
7+
component: ChatMessage,
8+
parameters: {
9+
layout: 'centered'
10+
}
11+
});
12+
13+
// Mock messages for different scenarios
14+
const userMessage: DatabaseMessage = {
15+
id: '1',
16+
convId: 'conv-1',
17+
type: 'message',
18+
timestamp: Date.now() - 1000 * 60 * 5,
19+
role: 'user',
20+
content: 'What is the meaning of life, the universe, and everything?',
21+
parent: '',
22+
thinking: '',
23+
children: []
24+
};
25+
26+
const assistantMessage: DatabaseMessage = {
27+
id: '2',
28+
convId: 'conv-1',
29+
type: 'message',
30+
timestamp: Date.now() - 1000 * 60 * 3,
31+
role: 'assistant',
32+
content: 'The answer to the ultimate question of life, the universe, and everything is **42**.\n\nThis comes from Douglas Adams\' "The Hitchhiker\'s Guide to the Galaxy," where a supercomputer named Deep Thought calculated this answer over 7.5 million years. However, the question itself was never properly formulated, which is why the answer seems meaningless without context.',
33+
parent: '1',
34+
thinking: '',
35+
children: []
36+
};
37+
38+
const thinkingMessage: DatabaseMessage = {
39+
id: '3',
40+
convId: 'conv-1',
41+
type: 'message',
42+
timestamp: Date.now() - 1000 * 60 * 2,
43+
role: 'assistant',
44+
content: 'Let me solve this step by step.\n\nFirst, I need to understand what you\'re asking for. Then I\'ll work through the problem systematically.',
45+
parent: '1',
46+
thinking: 'The user is asking me to solve a complex problem. I should break this down into steps:\n\n1. Understand the requirements\n2. Analyze the problem\n3. Consider different approaches\n4. Choose the best solution\n5. Implement and explain\n\nThis seems like a good approach to take.',
47+
children: []
48+
};
49+
50+
const processingMessage: DatabaseMessage = {
51+
id: '4',
52+
convId: 'conv-1',
53+
type: 'message',
54+
timestamp: Date.now(),
55+
role: 'assistant',
56+
content: '',
57+
parent: '1',
58+
thinking: '',
59+
children: []
60+
};
61+
</script>
62+
63+
<Story
64+
name="User"
65+
args={{
66+
message: userMessage
67+
}}
68+
/>
69+
70+
<Story
71+
name="Assistant"
72+
args={{
73+
message: assistantMessage
74+
}}
75+
/>
76+
77+
<Story
78+
name="ThinkingBlock"
79+
args={{
80+
message: thinkingMessage
81+
}}
82+
/>
83+
84+
<Story
85+
name="ProcessingState"
86+
args={{
87+
message: processingMessage
88+
}}
89+
play={({ canvasElement }) => {
90+
// Simulate processing state by setting up mock processing data
91+
const processingState = {
92+
slots: {
93+
'slot-1': { content: 'Processing your request...', timestamp: Date.now() },
94+
'slot-2': { content: 'Analyzing data...', timestamp: Date.now() + 1000 }
95+
}
96+
};
97+
98+
// This would normally be handled by the useProcessingState hook
99+
// but for Storybook we can simulate the visual state
100+
}}
101+
/>

tools/server/webui/src/stories/ChatSidebar.stories.svelte

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<script module>
1+
<script module lang="ts">
22
import { defineMeta } from '@storybook/addon-svelte-csf';
33
import ChatSidebar from '$lib/components/app/chat/ChatSidebar/ChatSidebar.svelte';
44
@@ -9,10 +9,64 @@
99
layout: 'centered'
1010
}
1111
});
12+
13+
// Mock conversations for the sidebar
14+
const mockConversations: DatabaseConversation[] = [
15+
{
16+
id: 'conv-1',
17+
name: 'Getting Started with AI',
18+
lastModified: Date.now() - 1000 * 60 * 5, // 5 minutes ago
19+
currNode: 'msg-1'
20+
},
21+
{
22+
id: 'conv-2',
23+
name: 'Python Programming Help',
24+
lastModified: Date.now() - 1000 * 60 * 60 * 2, // 2 hours ago
25+
currNode: 'msg-2'
26+
},
27+
{
28+
id: 'conv-3',
29+
name: 'Creative Writing Ideas',
30+
lastModified: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago
31+
currNode: 'msg-3'
32+
},
33+
{
34+
id: 'conv-4',
35+
name: 'This is a very long conversation title that should be truncated properly when displayed',
36+
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 3, // 3 days ago
37+
currNode: 'msg-4'
38+
},
39+
{
40+
id: 'conv-5',
41+
name: 'Math Problem Solving',
42+
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 7, // 1 week ago
43+
currNode: 'msg-5'
44+
}
45+
];
1246
</script>
1347

1448
<Story name="Default">
1549
<div class="bg-background h-screen w-80 border-r">
1650
<ChatSidebar />
1751
</div>
1852
</Story>
53+
54+
<Story
55+
name="SearchActive"
56+
play={async ({ canvasElement }) => {
57+
// Wait for component to mount
58+
await new Promise(resolve => setTimeout(resolve, 100));
59+
60+
// Find and interact with search input
61+
const searchInput = canvasElement.querySelector('input[placeholder*="Search"]') as HTMLInputElement;
62+
if (searchInput) {
63+
searchInput.focus();
64+
searchInput.value = 'Python';
65+
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
66+
}
67+
}}
68+
>
69+
<div class="bg-background h-screen w-80 border-r">
70+
<ChatSidebar />
71+
</div>
72+
</Story>

tools/server/webui/src/stories/ChatSidebarConversationItem.stories.svelte

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,23 @@
7070
onDelete: (id: string) => console.log('Delete:', id)
7171
}}
7272
/>
73+
74+
<Story
75+
name="ActionsOpen"
76+
args={{
77+
conversation: sampleConversation,
78+
onSelect: (id: string) => console.log('Selected:', id),
79+
onEdit: (id: string) => console.log('Edit:', id),
80+
onDelete: (id: string) => console.log('Delete:', id)
81+
}}
82+
play={async ({ canvasElement }) => {
83+
// Wait for component to mount
84+
await new Promise(resolve => setTimeout(resolve, 100));
85+
86+
// Find and click the more actions button (three dots)
87+
const moreButton = canvasElement.querySelector('[data-testid="more-actions"], button[aria-label*="More"], button:has([data-lucide="more-horizontal"])') as HTMLButtonElement;
88+
if (moreButton) {
89+
moreButton.click();
90+
}
91+
}}
92+
/>

tools/server/webui/src/stories/ChatSidebarSearch.stories.svelte

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@
1414
<Story name="Default" args={{ class: 'w-80' }} />
1515

1616
<Story name="Focus" args={{ class: 'w-80' }} play={({ canvasElement }) => {
17-
canvasElement.querySelector('input')?.focus();
17+
const input = canvasElement.querySelector('input') as HTMLInputElement;
18+
if (input) {
19+
input.focus();
20+
}
1821
}}/>
1922

2023
<Story
21-
name="WithValue"
24+
name="Active"
2225
args={{
2326
class: 'w-80',
24-
value: 'Hello World'
27+
value: 'Python programming'
2528
}}
2629
/>

tools/server/webui/src/stories/MarkdownContent.stories.svelte

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,6 @@
1616
layout: 'centered'
1717
}
1818
});
19-
20-
21-
22-
23-
24-
25-
26-
27-
2819
</script>
2920

3021
<Story

0 commit comments

Comments
 (0)