diff --git a/Makefile b/Makefile index 6abb359..79de17a 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,8 @@ help: @echo " make check - Check syntax and linting with biome" @echo " make format-check - Format and check in sequence" @echo " make test - Run integration tests" + @echo " make test-jest - Run Jest unit tests for modals" + @echo " make test-jest-coverage - Run Jest tests with coverage report" @echo " make validate-exports - Validate import/export consistency" @echo " make verify-translations - Verify translation completeness" @echo " make validate-translations - Validate all translation files" @@ -58,7 +60,31 @@ validate-exports: echo "❌ Node.js not available"; \ fi -validate-all: format-check validate-exports verify-translations test +test-jest: + @echo "Running Jest unit tests for modals..." + @if command -v npm >/dev/null 2>&1; then \ + if npm list jest >/dev/null 2>&1; then \ + npm test; \ + else \ + echo "⚠️ Jest not installed - skipping Jest tests"; \ + fi; \ + else \ + echo "❌ npm not available"; \ + fi + +test-jest-coverage: + @echo "Running Jest tests with coverage..." + @if command -v npm >/dev/null 2>&1; then \ + if npm list jest >/dev/null 2>&1; then \ + npm run test:coverage; \ + else \ + echo "⚠️ Jest not installed - skipping Jest tests"; \ + fi; \ + else \ + echo "❌ npm not available"; \ + fi + +validate-all: format-check validate-exports verify-translations test test-jest # Development workflow install: @@ -145,5 +171,5 @@ quick: format-check git-add @echo "Quick validation complete - ready to commit" # Full CI/CD cycle -ci: clean format-check validate-all validate-structure validate-translations check-sizes +ci: clean format-check validate-all validate-structure validate-translations test-jest-coverage check-sizes @echo "CI pipeline complete" \ No newline at end of file diff --git a/package.json b/package.json index 8f0303f..21b29cd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,17 @@ "type": "module", "main": "integration_tests.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:modals": "jest tests/modals", + "test:verbose": "jest --verbose" + }, + "devDependencies": { + "@jest/globals": "^29.0.0", + "jest": "^29.0.0", + "jest-environment-jsdom": "^29.0.0", + "jsdom": "^22.0.0" }, "repository": { "type": "git", diff --git a/tests/modals/embed-modal.test.js b/tests/modals/embed-modal.test.js new file mode 100644 index 0000000..ddba952 --- /dev/null +++ b/tests/modals/embed-modal.test.js @@ -0,0 +1,683 @@ +// Jest tests for Embed Modal (Main Chat Modal) functionality +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +// Mock the dependencies before importing the module +jest.mock('../../src/config.js', () => ({ + API_KEY: 'mock-api-key', + TTS_API_URL: 'https://mock-tts-api.com', + setSystemMessageAppend: jest.fn(), + getSystemMessage: jest.fn(() => 'You are Hermes AI assistant.') +})); + +jest.mock('../../src/ui-themes.js', () => ({ + detectCurrentTheme: jest.fn(() => 'light'), + getThemeColors: jest.fn(() => ({ + bg: '#ffffff', + text: '#000000', + accent: '#007bff', + border: '#dddddd', + actionBg: '#f8f9fa' + })), + initializeChunkFiveFont: jest.fn() +})); + +jest.mock('../../src/ui-translations.js', () => ({ + getUIText: jest.fn((key) => `mock-${key}`), + getUserLanguagePreference: jest.fn(() => 'en'), + setUserLanguagePreference: jest.fn() +})); + +jest.mock('../../src/content.js', () => ({ + extractWebpageContent: jest.fn(() => 'Mock webpage content for context.') +})); + +jest.mock('../../src/language-detection.js', () => ({ + detectPageLanguage: jest.fn(() => Promise.resolve('en')) +})); + +jest.mock('../../src/translation.js', () => ({ + NATIVE_LANGUAGE_NAMES: { + 'en': 'English', + 'es': 'Español', + 'fr': 'Français' + } +})); + +// Import the modules under test +let openUncloseaiEmbeddedModal, toggleUncloseaiEmbeddedModal; + +describe('Embed Modal (Main Chat Modal)', () => { + beforeEach(async () => { + // Reset all mocks + global.resetMocks(); + + // Clear any existing modals + const existingModal = document.getElementById('uncloseai-embedded-modal'); + if (existingModal) { + existingModal.remove(); + } + + // Mock fetch for chat API calls + global.fetch.mockImplementation((url, options) => { + if (url.includes('chat/completions')) { + return Promise.resolve({ + ok: true, + body: { + getReader: () => ({ + read: jest.fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode('data: {"choices":[{"delta":{"content":"Hello! How can I help you today?"}}]}\n\n') + }) + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode('data: {"choices":[{"delta":{"content":" I\'m here to assist."}}]}\n\n') + }) + .mockResolvedValueOnce({ + done: true + }) + }) + } + }); + } + if (url.includes('models')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + data: [ + { id: 'hermes-model-1', object: 'model' }, + { id: 'hermes-model-2', object: 'model' } + ] + }) + }); + } + return Promise.reject(new Error('Unexpected URL')); + }); + + // Mock localStorage for conversation history + global.localStorage.getItem.mockImplementation((key) => { + if (key.includes('hermes-conversation-history')) { + return JSON.stringify([ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' } + ]); + } + return null; + }); + + // Dynamically import the module to ensure fresh state + const module = await import('../../src/uncloseai-embed-modal.js'); + openUncloseaiEmbeddedModal = module.openUncloseaiEmbeddedModal; + toggleUncloseaiEmbeddedModal = module.toggleUncloseaiEmbeddedModal; + }); + + describe('Modal Creation and Display', () => { + test('should create embed modal with correct structure', async () => { + await openUncloseaiEmbeddedModal(); + + const modal = document.getElementById('uncloseai-embedded-modal'); + expect(modal).toBeTruthy(); + expect(modal.tagName).toBe('DIALOG'); + + // Check modal structure + const article = modal.querySelector('article'); + expect(article).toBeTruthy(); + + const header = modal.querySelector('header'); + expect(header).toBeTruthy(); + + const chatContainer = modal.querySelector('#hermes-chat-container, [data-chat-container]'); + expect(chatContainer).toBeTruthy(); + }); + + test('should open modal with showModal method', async () => { + const showModalSpy = jest.spyOn(HTMLDialogElement.prototype, 'showModal'); + + await openUncloseaiEmbeddedModal(); + + expect(showModalSpy).toHaveBeenCalled(); + + const modal = document.getElementById('uncloseai-embedded-modal'); + expect(modal.open).toBe(true); + }); + + test('should set window flag when modal is open', async () => { + await openUncloseaiEmbeddedModal(); + + expect(window.uncloseaiEmbeddedModalOpen).toBe(true); + }); + + test('should prevent opening multiple embed modals', async () => { + await openUncloseaiEmbeddedModal(); + const firstModal = document.getElementById('uncloseai-embedded-modal'); + + await openUncloseaiEmbeddedModal(); + const modals = document.querySelectorAll('#uncloseai-embedded-modal'); + + expect(modals.length).toBe(1); + expect(firstModal).toBe(document.getElementById('uncloseai-embedded-modal')); + }); + + test('should create chat input and send button', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + expect(input).toBeTruthy(); + expect(input.type).toBe('text'); + + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + expect(sendButton).toBeTruthy(); + }); + }); + + describe('Model Loading and Selection', () => { + test('should load available models on startup', async () => { + await openUncloseaiEmbeddedModal(); + + // Wait for models to load + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('models'), + expect.any(Object) + ); + }); + + test('should create model selection dropdown', async () => { + await openUncloseaiEmbeddedModal(); + + // Wait for models to load + await new Promise(resolve => setTimeout(resolve, 100)); + + const modelSelect = document.querySelector('#hermes-model-selection, select[data-model]'); + expect(modelSelect).toBeTruthy(); + }); + + test('should populate model options', async () => { + await openUncloseaiEmbeddedModal(); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const modelSelect = document.querySelector('#hermes-model-selection, select[data-model]'); + if (modelSelect) { + const options = modelSelect.querySelectorAll('option'); + expect(options.length).toBeGreaterThan(0); + } + }); + + test('should handle model loading errors gracefully', async () => { + global.fetch.mockImplementationOnce(() => Promise.reject(new Error('Models API Error'))); + + expect(async () => { + await openUncloseaiEmbeddedModal(); + await new Promise(resolve => setTimeout(resolve, 100)); + }).not.toThrow(); + }); + }); + + describe('Conversation History', () => { + test('should load conversation history from localStorage', async () => { + await openUncloseaiEmbeddedModal(); + + // Wait for history to load + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(global.localStorage.getItem).toHaveBeenCalledWith( + expect.stringContaining('hermes-conversation-history') + ); + }); + + test('should display loaded conversation history', async () => { + await openUncloseaiEmbeddedModal(); + + await new Promise(resolve => setTimeout(resolve, 150)); + + const chatContainer = document.querySelector('#hermes-chat-container, [data-chat-container]'); + const messages = chatContainer.querySelectorAll('.message, [data-message]'); + + // Should have at least the intro message + expect(messages.length).toBeGreaterThan(0); + }); + + test('should save conversation history to localStorage on new messages', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Test message'; + sendButton.click(); + + // Wait for message processing + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(global.localStorage.setItem).toHaveBeenCalledWith( + expect.stringContaining('hermes-conversation-history'), + expect.any(String) + ); + }); + + test('should handle empty conversation history gracefully', async () => { + global.localStorage.getItem.mockReturnValue(null); + + expect(async () => { + await openUncloseaiEmbeddedModal(); + await new Promise(resolve => setTimeout(resolve, 100)); + }).not.toThrow(); + }); + }); + + describe('Chat Functionality', () => { + test('should send message when send button is clicked', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Hello, Hermes!'; + sendButton.click(); + + // Wait for API call + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('chat/completions'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer mock-api-key' + }), + body: expect.stringContaining('Hello, Hermes!') + }) + ); + }); + + test('should send message when Enter key is pressed', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + + input.value = 'Test message via Enter'; + const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' }); + input.dispatchEvent(enterEvent); + + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('chat/completions'), + expect.any(Object) + ); + }); + + test('should clear input after sending message', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Test message'; + sendButton.click(); + + expect(input.value).toBe(''); + }); + + test('should disable send button during message processing', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Test message'; + sendButton.click(); + + expect(sendButton.disabled).toBe(true); + }); + + test('should display streaming AI response', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Hello'; + sendButton.click(); + + // Wait for streaming response + await new Promise(resolve => setTimeout(resolve, 200)); + + const chatContainer = document.querySelector('#hermes-chat-container, [data-chat-container]'); + const aiMessage = chatContainer.querySelector('[data-role="assistant"], .ai-message'); + + expect(aiMessage).toBeTruthy(); + expect(aiMessage.textContent).toContain('Hello! How can I help you today?'); + }); + + test('should handle empty message input gracefully', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = ''; + sendButton.click(); + + // Should not make API call for empty message + expect(global.fetch).not.toHaveBeenCalledWith( + expect.stringContaining('chat/completions'), + expect.any(Object) + ); + }); + }); + + describe('Action Buttons', () => { + test('should create action buttons for TTS and Translation', async () => { + await openUncloseaiEmbeddedModal(); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const actionButtons = document.querySelectorAll('button[data-action], .action-btn'); + const buttonTexts = Array.from(actionButtons).map(btn => btn.textContent); + + expect(buttonTexts.some(text => text.includes('mock-ttsAnything'))).toBeTruthy(); + expect(buttonTexts.some(text => text.includes('mock-translationModal'))).toBeTruthy(); + }); + + test('should handle TTS modal opening with fallback', async () => { + // Mock TTS modal not available on window + delete window.openTTSModal; + + await openUncloseaiEmbeddedModal(); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const ttsButton = Array.from(document.querySelectorAll('button')).find( + btn => btn.textContent.includes('mock-ttsAnything') + ); + + expect(async () => { + if (ttsButton) { + ttsButton.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + }).not.toThrow(); + }); + + test('should handle Translation modal opening with fallback', async () => { + // Mock Translation modal not available on window + delete window.openTranslateModal; + + await openUncloseaiEmbeddedModal(); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const translateButton = Array.from(document.querySelectorAll('button')).find( + btn => btn.textContent.includes('mock-translationModal') + ); + + expect(async () => { + if (translateButton) { + translateButton.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + }).not.toThrow(); + }); + + test('should create refresh models button', async () => { + await openUncloseaiEmbeddedModal(); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const refreshButton = Array.from(document.querySelectorAll('button')).find( + btn => btn.textContent.includes('mock-refreshModels') + ); + + expect(refreshButton).toBeTruthy(); + }); + + test('should create clear chat button', async () => { + await openUncloseaiEmbeddedModal(); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const clearButton = Array.from(document.querySelectorAll('button')).find( + btn => btn.textContent.includes('mock-clearChat') + ); + + expect(clearButton).toBeTruthy(); + }); + }); + + describe('Message Actions', () => { + test('should add copy button to messages', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Test message'; + sendButton.click(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const copyButtons = document.querySelectorAll('[data-copy], .copy-btn'); + expect(copyButtons.length).toBeGreaterThan(0); + }); + + test('should add delete button to user messages', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Test message'; + sendButton.click(); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const deleteButtons = document.querySelectorAll('[data-delete], .delete-btn'); + expect(deleteButtons.length).toBeGreaterThan(0); + }); + + test('should copy message to clipboard when copy button is clicked', async () => { + // Mock clipboard API + Object.assign(navigator, { + clipboard: { + writeText: jest.fn(() => Promise.resolve()) + } + }); + + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Test message for copying'; + sendButton.click(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const copyButton = document.querySelector('[data-copy], .copy-btn'); + if (copyButton) { + copyButton.click(); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('Test message for copying') + ); + } + }); + + test('should delete message when delete button is clicked', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Message to delete'; + sendButton.click(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const initialMessages = document.querySelectorAll('.message, [data-message]').length; + + const deleteButton = document.querySelector('[data-delete], .delete-btn'); + if (deleteButton) { + deleteButton.click(); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const finalMessages = document.querySelectorAll('.message, [data-message]').length; + expect(finalMessages).toBeLessThan(initialMessages); + } + }); + }); + + describe('Toggle Functionality', () => { + test('should toggle modal open and closed', async () => { + // First toggle - should open + await toggleUncloseaiEmbeddedModal(); + + let modal = document.getElementById('uncloseai-embedded-modal'); + expect(modal).toBeTruthy(); + expect(window.uncloseaiEmbeddedModalOpen).toBe(true); + + // Second toggle - should close + await toggleUncloseaiEmbeddedModal(); + + expect(window.uncloseaiEmbeddedModalOpen).toBe(false); + }); + + test('should close modal when close button is clicked', async () => { + await openUncloseaiEmbeddedModal(); + + const modal = document.getElementById('uncloseai-embedded-modal'); + const closeButton = modal.querySelector('button[data-close], .close-btn'); + + const closeSpy = jest.spyOn(modal, 'close'); + closeButton.click(); + + expect(closeSpy).toHaveBeenCalled(); + }); + + test('should clean up modal state when closed', async () => { + await openUncloseaiEmbeddedModal(); + + const modal = document.getElementById('uncloseai-embedded-modal'); + const closeButton = modal.querySelector('button[data-close], .close-btn'); + + closeButton.click(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(window.uncloseaiEmbeddedModalOpen).toBe(false); + expect(document.getElementById('uncloseai-embedded-modal')).toBeFalsy(); + }); + }); + + describe('Accessibility', () => { + test('should have proper ARIA attributes', async () => { + await openUncloseaiEmbeddedModal(); + + const modal = document.getElementById('uncloseai-embedded-modal'); + + expect(modal.getAttribute('role') || modal.tagName === 'DIALOG').toBeTruthy(); + expect(modal.getAttribute('aria-modal')).toBe('true'); + + const input = modal.querySelector('input'); + expect(input.getAttribute('aria-label')).toBeTruthy(); + }); + + test('should focus input when modal opens', async () => { + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + expect(document.activeElement).toBe(input); + }); + + test('should handle keyboard navigation', async () => { + await openUncloseaiEmbeddedModal(); + + const modal = document.getElementById('uncloseai-embedded-modal'); + const focusableElements = modal.querySelectorAll( + 'button, input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + expect(focusableElements.length).toBeGreaterThan(1); + }); + }); + + describe('Error Handling', () => { + test('should handle chat API errors gracefully', async () => { + global.fetch.mockImplementationOnce(() => Promise.reject(new Error('Chat API Error'))); + + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Test message'; + + expect(async () => { + sendButton.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + }).not.toThrow(); + }); + + test('should handle malformed API responses', async () => { + global.fetch.mockImplementationOnce(() => Promise.resolve({ + ok: true, + body: { + getReader: () => ({ + read: jest.fn().mockResolvedValue({ + done: false, + value: new TextEncoder().encode('invalid json response') + }) + }) + } + })); + + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Test message'; + + expect(async () => { + sendButton.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + }).not.toThrow(); + }); + + test('should handle localStorage errors gracefully', async () => { + global.localStorage.getItem.mockImplementation(() => { + throw new Error('localStorage error'); + }); + + expect(async () => { + await openUncloseaiEmbeddedModal(); + await new Promise(resolve => setTimeout(resolve, 100)); + }).not.toThrow(); + }); + + test('should handle network timeouts', async () => { + global.fetch.mockImplementationOnce(() => + new Promise((resolve, reject) => { + setTimeout(() => reject(new Error('Network timeout')), 50); + }) + ); + + await openUncloseaiEmbeddedModal(); + + const input = document.querySelector('#hermes-user-input, input[data-user-input]'); + const sendButton = document.querySelector('#hermes-send-btn, button[data-send]'); + + input.value = 'Test message'; + + expect(async () => { + sendButton.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + }).not.toThrow(); + }); + }); +}); \ No newline at end of file