diff --git a/tests/e2e/chatbot.spec.ts b/tests/e2e/chatbot.spec.ts new file mode 100644 index 0000000..6c0d522 --- /dev/null +++ b/tests/e2e/chatbot.spec.ts @@ -0,0 +1,709 @@ +import { test, expect } from '@playwright/test'; + +/** + * Chatbot E2E Test Suite + * + * This suite tests the AI chatbot functionality including: + * - Opening and closing the chat window + * - Message flow (sending and receiving) + * - Error handling and validation + * - UI interactions (clear chat, suggested prompts) + * - Accessibility features + * + * Note: These tests use mocked API responses for reliability. + * Integration tests with the real API should be run separately. + */ + +/** + * Chatbot UI Tests + * + * Verify that the chatbot UI renders correctly and interactions work. + */ +test.describe('Chatbot UI', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/de', { + waitUntil: 'networkidle', + }); + }); + + test('Chatbot floating button is visible', async ({ page }) => { + // Verify the floating chat button exists + const chatButton = page.locator('button[aria-label="Open chat"]'); + await expect(chatButton).toBeVisible(); + }); + + test('Chatbot opens when floating button is clicked', async ({ page }) => { + // Click the chat button + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Verify chat window opens + const chatWindow = page.locator('.fixed.z-50.bg-zinc-900'); + await expect(chatWindow).toBeVisible(); + + // Verify chat header is visible + const chatHeader = page.locator('text=AI Assistant'); + await expect(chatHeader).toBeVisible(); + }); + + test('Chatbot closes when close button is clicked', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Wait for chat window to be visible + const chatWindow = page.locator('.fixed.z-50.bg-zinc-900'); + await expect(chatWindow).toBeVisible(); + + // Click close button + const closeButton = page.locator('button[aria-label="Close chat"]'); + await closeButton.click(); + + // Wait for animation and verify chat is closed + await page.waitForTimeout(300); + + // Floating button should be visible again + await expect(chatButton).toBeVisible(); + }); + + test('Chatbot shows welcome message when empty', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Verify welcome message + const welcomeHeading = page.locator("text=Hi, I'm Damjan's AI Assistant"); + await expect(welcomeHeading).toBeVisible(); + + // Verify help text + const helpText = page.locator('text=I can help you learn about'); + await expect(helpText).toBeVisible(); + }); + + test('Chatbot shows suggested prompts when empty', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Verify "Try asking:" label + const tryAskingLabel = page.locator('text=Try asking:'); + await expect(tryAskingLabel).toBeVisible(); + + // Verify suggested prompts are visible + const suggestedPrompts = page.locator('button:has-text("What services do you offer?")'); + await expect(suggestedPrompts).toBeVisible(); + + const experiencePrompt = page.locator('button:has-text("Tell me about your experience")'); + await expect(experiencePrompt).toBeVisible(); + + const techPrompt = page.locator('button:has-text("What technologies do you work with?")'); + await expect(techPrompt).toBeVisible(); + }); + + test('Chat input field is present and functional', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Verify input field + const inputField = page.locator('input[aria-label="Chat message input"]'); + await expect(inputField).toBeVisible(); + await expect(inputField).toBeEnabled(); + + // Verify placeholder + await expect(inputField).toHaveAttribute('placeholder', 'Type a message...'); + + // Type in input + await inputField.fill('Test message'); + await expect(inputField).toHaveValue('Test message'); + }); + + test('Send button is disabled when input is empty', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Verify send button is disabled with empty input + const sendButton = page.locator('button[aria-label="Send message"]'); + await expect(sendButton).toBeDisabled(); + + // Type in input + const inputField = page.locator('input[aria-label="Chat message input"]'); + await inputField.fill('Test message'); + + // Verify send button is now enabled + await expect(sendButton).toBeEnabled(); + }); + + test('Privacy note is displayed', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Verify privacy note + const privacyNote = page.locator('text=Messages may be stored to improve service'); + await expect(privacyNote).toBeVisible(); + }); +}); + +/** + * Chatbot Message Flow Tests + * + * Verify that sending and receiving messages works correctly. + * These tests mock API responses for reliability. + */ +test.describe('Chatbot Message Flow', () => { + test.beforeEach(async ({ page }) => { + // Mock the chat API endpoint + await page.route('**/api/chat', async (route) => { + const request = route.request(); + const method = request.method(); + + if (method === 'POST') { + // Simulate streaming response for POST + const encoder = new TextEncoder(); + const chunks = [ + 'data: {"content":"Hello","sessionId":"test-session"}\n\n', + 'data: {"content":"! How can I","sessionId":"test-session"}\n\n', + 'data: {"content":" help you today?","sessionId":"test-session"}\n\n', + 'data: {"done":true,"sessionId":"test-session"}\n\n', + ]; + + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + body: chunks.join(''), + }); + } else if (method === 'GET') { + // Return empty history for GET + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ messages: [], sessionId: 'test-session' }), + }); + } + }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + }); + + test('Sending a message shows user message in chat', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Type and send message + const inputField = page.locator('input[aria-label="Chat message input"]'); + await inputField.fill('Hello, how are you?'); + + const sendButton = page.locator('button[aria-label="Send message"]'); + await sendButton.click(); + + // Verify user message appears in chat + const userMessage = page.locator('text=Hello, how are you?'); + await expect(userMessage).toBeVisible(); + + // Input should be cleared after sending + await expect(inputField).toHaveValue(''); + }); + + test('Sending a message triggers API response', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Type and send message + const inputField = page.locator('input[aria-label="Chat message input"]'); + await inputField.fill('Hello'); + + const sendButton = page.locator('button[aria-label="Send message"]'); + await sendButton.click(); + + // Wait for assistant response + const assistantMessage = page.locator('text=Hello! How can I help you today?'); + await expect(assistantMessage).toBeVisible({ timeout: 5000 }); + }); + + test('Clicking suggested prompt sends message', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Click suggested prompt + const suggestedPrompt = page.locator('button:has-text("What services do you offer?")'); + await suggestedPrompt.click(); + + // Verify user message appears + const userMessage = page.locator('text=What services do you offer?'); + await expect(userMessage).toBeVisible(); + }); + + test('Enter key submits the message', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Type message and press Enter + const inputField = page.locator('input[aria-label="Chat message input"]'); + await inputField.fill('Test enter submission'); + await inputField.press('Enter'); + + // Verify user message appears + const userMessage = page.locator('text=Test enter submission'); + await expect(userMessage).toBeVisible(); + }); + + test('Input is disabled during message sending', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Type and send message + const inputField = page.locator('input[aria-label="Chat message input"]'); + await inputField.fill('Test message'); + + const sendButton = page.locator('button[aria-label="Send message"]'); + await sendButton.click(); + + // Input should be disabled during loading + await expect(inputField).toBeDisabled(); + + // Wait for response to complete + await page.waitForTimeout(500); + }); +}); + +/** + * Chatbot Error Handling Tests + * + * Verify that errors are handled gracefully. + */ +test.describe('Chatbot Error Handling', () => { + test('Shows error message when API fails', async ({ page }) => { + // Mock API to return error + await page.route('**/api/chat', async (route) => { + const method = route.request().method(); + + if (method === 'POST') { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Internal server error' }), + }); + } else if (method === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ messages: [], sessionId: 'test-session' }), + }); + } + }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Send message + const inputField = page.locator('input[aria-label="Chat message input"]'); + await inputField.fill('Test message'); + + const sendButton = page.locator('button[aria-label="Send message"]'); + await sendButton.click(); + + // Wait for error message + const errorContainer = page.locator('.bg-red-900\\/20'); + await expect(errorContainer).toBeVisible({ timeout: 5000 }); + }); + + test('Error message can be dismissed', async ({ page }) => { + // Mock API to return error + await page.route('**/api/chat', async (route) => { + const method = route.request().method(); + + if (method === 'POST') { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Test error' }), + }); + } else if (method === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ messages: [], sessionId: 'test-session' }), + }); + } + }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Send message to trigger error + const inputField = page.locator('input[aria-label="Chat message input"]'); + await inputField.fill('Test message'); + + const sendButton = page.locator('button[aria-label="Send message"]'); + await sendButton.click(); + + // Wait for error and dismiss it + const errorContainer = page.locator('.bg-red-900\\/20'); + await expect(errorContainer).toBeVisible({ timeout: 5000 }); + + // Click dismiss button + const dismissButton = page.locator('text=Dismiss'); + await dismissButton.click(); + + // Error should be hidden + await expect(errorContainer).not.toBeVisible(); + }); + + test('Handles streaming error gracefully', async ({ page }) => { + // Mock API to return error in stream + await page.route('**/api/chat', async (route) => { + const method = route.request().method(); + + if (method === 'POST') { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + body: 'data: {"error":"Stream error occurred"}\n\n', + }); + } else if (method === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ messages: [], sessionId: 'test-session' }), + }); + } + }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Send message + const inputField = page.locator('input[aria-label="Chat message input"]'); + await inputField.fill('Test message'); + + const sendButton = page.locator('button[aria-label="Send message"]'); + await sendButton.click(); + + // Error should be shown + const errorContainer = page.locator('.bg-red-900\\/20'); + await expect(errorContainer).toBeVisible({ timeout: 5000 }); + }); +}); + +/** + * Chatbot Clear Chat Tests + * + * Verify that clearing chat works correctly. + */ +test.describe('Chatbot Clear Chat', () => { + test.beforeEach(async ({ page }) => { + // Mock the chat API endpoint + await page.route('**/api/chat', async (route) => { + const method = route.request().method(); + + if (method === 'POST') { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + body: + 'data: {"content":"Test response","sessionId":"test-session"}\n\ndata: {"done":true,"sessionId":"test-session"}\n\n', + }); + } else if (method === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ messages: [], sessionId: 'test-session' }), + }); + } + }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + }); + + test('Clear chat button appears after sending message', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Clear button should not be visible initially (no messages) + const clearButton = page.locator('button[aria-label="Clear chat"]'); + await expect(clearButton).not.toBeVisible(); + + // Send a message + const inputField = page.locator('input[aria-label="Chat message input"]'); + await inputField.fill('Test message'); + + const sendButton = page.locator('button[aria-label="Send message"]'); + await sendButton.click(); + + // Wait for message to appear + await page.waitForTimeout(500); + + // Clear button should now be visible + await expect(clearButton).toBeVisible(); + }); + + test('Clicking clear chat removes all messages', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Send a message + const inputField = page.locator('input[aria-label="Chat message input"]'); + await inputField.fill('Test message'); + + const sendButton = page.locator('button[aria-label="Send message"]'); + await sendButton.click(); + + // Wait for messages + await page.waitForTimeout(1000); + + // Click clear button + const clearButton = page.locator('button[aria-label="Clear chat"]'); + await clearButton.click(); + + // Verify messages are cleared and welcome message returns + const welcomeHeading = page.locator("text=Hi, I'm Damjan's AI Assistant"); + await expect(welcomeHeading).toBeVisible(); + + // Clear button should be hidden again + await expect(clearButton).not.toBeVisible(); + }); +}); + +/** + * Chatbot Accessibility Tests + * + * Verify that the chatbot is accessible. + */ +test.describe('Chatbot Accessibility', () => { + test.beforeEach(async ({ page }) => { + // Mock the chat API + await page.route('**/api/chat', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ messages: [], sessionId: 'test-session' }), + }); + } + }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + }); + + test('Chatbot floating button has aria-label', async ({ page }) => { + const chatButton = page.locator('button[aria-label="Open chat"]'); + await expect(chatButton).toBeVisible(); + await expect(chatButton).toHaveAttribute('aria-label', 'Open chat'); + }); + + test('Close button has aria-label', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + const closeButton = page.locator('button[aria-label="Close chat"]'); + await expect(closeButton).toBeVisible(); + await expect(closeButton).toHaveAttribute('aria-label', 'Close chat'); + }); + + test('Input field has aria-label', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + const inputField = page.locator('input[aria-label="Chat message input"]'); + await expect(inputField).toBeVisible(); + await expect(inputField).toHaveAttribute('aria-label', 'Chat message input'); + }); + + test('Send button has aria-label', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + const sendButton = page.locator('button[aria-label="Send message"]'); + await expect(sendButton).toBeVisible(); + await expect(sendButton).toHaveAttribute('aria-label', 'Send message'); + }); + + test('Input field receives focus when chat opens', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Wait for focus to be set + await page.waitForTimeout(200); + + // Verify input has focus + const inputField = page.locator('input[aria-label="Chat message input"]'); + await expect(inputField).toBeFocused(); + }); +}); + +/** + * Chatbot Mobile Tests + * + * Verify that the chatbot works correctly on mobile devices. + */ +test.describe('Chatbot Mobile', () => { + test.use({ viewport: { width: 375, height: 812 } }); + + test.beforeEach(async ({ page }) => { + // Mock the chat API + await page.route('**/api/chat', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ messages: [], sessionId: 'test-session' }), + }); + } + }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + }); + + test('Chatbot floating button is visible on mobile', async ({ page }) => { + const chatButton = page.locator('button[aria-label="Open chat"]'); + await expect(chatButton).toBeVisible(); + }); + + test('Chatbot opens correctly on mobile', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Verify chat window is visible + const chatWindow = page.locator('.fixed.z-50.bg-zinc-900'); + await expect(chatWindow).toBeVisible(); + + // Verify chat is usable + const inputField = page.locator('input[aria-label="Chat message input"]'); + await expect(inputField).toBeVisible(); + }); + + test('Chat window does not overflow on mobile', async ({ page }) => { + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Get chat window dimensions + const chatWindow = page.locator('.fixed.z-50.bg-zinc-900'); + const box = await chatWindow.boundingBox(); + + expect(box).not.toBeNull(); + if (box) { + // Chat window should fit within viewport + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(375 + 1); // Allow 1px tolerance + } + }); +}); + +/** + * Chatbot Chat History Tests + * + * Verify that chat history is loaded correctly. + */ +test.describe('Chatbot Chat History', () => { + test('Loads chat history when opening', async ({ page }) => { + // Mock API with existing history + await page.route('**/api/chat', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + messages: [ + { id: '1', role: 'user', content: 'Previous question', created_at: new Date().toISOString() }, + { id: '2', role: 'assistant', content: 'Previous answer', created_at: new Date().toISOString() }, + ], + sessionId: 'existing-session', + }), + }); + } + }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Verify previous messages are loaded + await page.waitForTimeout(500); + + const previousQuestion = page.locator('text=Previous question'); + await expect(previousQuestion).toBeVisible(); + + const previousAnswer = page.locator('text=Previous answer'); + await expect(previousAnswer).toBeVisible(); + }); + + test('Shows welcome message when no history exists', async ({ page }) => { + // Mock API with empty history + await page.route('**/api/chat', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + messages: [], + sessionId: 'new-session', + }), + }); + } + }); + + await page.goto('/de', { + waitUntil: 'networkidle', + }); + + // Open chat + const chatButton = page.locator('button[aria-label="Open chat"]'); + await chatButton.click(); + + // Verify welcome message is shown + const welcomeHeading = page.locator("text=Hi, I'm Damjan's AI Assistant"); + await expect(welcomeHeading).toBeVisible(); + }); +});