diff --git a/web-app/src/__tests__/i18n.test.ts b/web-app/src/__tests__/i18n.test.ts
new file mode 100644
index 000000000..644bc019d
--- /dev/null
+++ b/web-app/src/__tests__/i18n.test.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect, vi } from 'vitest'
+
+// Mock the dependencies
+vi.mock('@/i18n/setup', () => ({
+ default: { t: vi.fn(), init: vi.fn() },
+}))
+
+vi.mock('@/i18n/react-i18next-compat', () => ({
+ useTranslation: vi.fn(() => ({ t: vi.fn() })),
+}))
+
+vi.mock('@/i18n/hooks', () => ({
+ useAppTranslation: vi.fn(() => ({ t: vi.fn() })),
+}))
+
+vi.mock('@/i18n/TranslationContext', () => ({
+ TranslationProvider: vi.fn(({ children }) => children),
+}))
+
+describe('i18n module', () => {
+ it('should re-export default from i18n/setup', async () => {
+ const i18nModule = await import('../i18n')
+ expect(i18nModule.default).toBeDefined()
+ })
+
+ it('should re-export useTranslation', async () => {
+ const i18nModule = await import('../i18n')
+ expect(i18nModule.useTranslation).toBeDefined()
+ expect(typeof i18nModule.useTranslation).toBe('function')
+ })
+
+ it('should re-export useAppTranslation', async () => {
+ const i18nModule = await import('../i18n')
+ expect(i18nModule.useAppTranslation).toBeDefined()
+ expect(typeof i18nModule.useAppTranslation).toBe('function')
+ })
+
+ it('should re-export TranslationProvider', async () => {
+ const i18nModule = await import('../i18n')
+ expect(i18nModule.TranslationProvider).toBeDefined()
+ expect(typeof i18nModule.TranslationProvider).toBe('function')
+ })
+
+ it('should export all expected functions', async () => {
+ const i18nModule = await import('../i18n')
+ const expectedExports = ['default', 'useTranslation', 'useAppTranslation', 'TranslationProvider']
+
+ expectedExports.forEach(exportName => {
+ expect(i18nModule[exportName]).toBeDefined()
+ })
+ })
+})
\ No newline at end of file
diff --git a/web-app/src/components/ui/__tests__/hover-card.test.tsx b/web-app/src/components/ui/__tests__/hover-card.test.tsx
new file mode 100644
index 000000000..71e78cb7f
--- /dev/null
+++ b/web-app/src/components/ui/__tests__/hover-card.test.tsx
@@ -0,0 +1,168 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { HoverCard, HoverCardTrigger, HoverCardContent } from '../hover-card'
+
+// Mock Radix UI
+vi.mock('@radix-ui/react-hover-card', () => ({
+ Root: ({ children, ...props }: any) =>
{children}
,
+ Trigger: ({ children, ...props }: any) => ,
+ Portal: ({ children, ...props }: any) => {children}
,
+ Content: ({ children, className, align, sideOffset, ...props }: any) => (
+
+ {children}
+
+ ),
+}))
+
+describe('HoverCard Components', () => {
+ describe('HoverCard', () => {
+ it('should render HoverCard root component', () => {
+ render(
+
+ Test content
+
+ )
+
+ const hoverCard = screen.getByTestId('hover-card-root')
+ expect(hoverCard).toBeDefined()
+ expect(hoverCard).toHaveAttribute('data-slot', 'hover-card')
+ expect(screen.getByText('Test content')).toBeDefined()
+ })
+
+ it('should pass through props to root component', () => {
+ render(
+
+ Test content
+
+ )
+
+ const hoverCard = screen.getByTestId('hover-card-root')
+ expect(hoverCard).toHaveAttribute('openDelay', '500')
+ })
+ })
+
+ describe('HoverCardTrigger', () => {
+ it('should render trigger component', () => {
+ render(
+
+ Hover me
+
+ )
+
+ const trigger = screen.getByTestId('hover-card-trigger')
+ expect(trigger).toBeDefined()
+ expect(trigger).toHaveAttribute('data-slot', 'hover-card-trigger')
+ expect(screen.getByText('Hover me')).toBeDefined()
+ })
+
+ it('should pass through props to trigger component', () => {
+ render(
+
+ Disabled trigger
+
+ )
+
+ const trigger = screen.getByTestId('hover-card-trigger')
+ expect(trigger).toHaveAttribute('disabled')
+ })
+ })
+
+ describe('HoverCardContent', () => {
+ it('should render content with default props', () => {
+ render(
+
+ Content here
+
+ )
+
+ const portal = screen.getByTestId('hover-card-portal')
+ expect(portal).toHaveAttribute('data-slot', 'hover-card-portal')
+
+ const content = screen.getByTestId('hover-card-content')
+ expect(content).toBeDefined()
+ expect(content).toHaveAttribute('data-slot', 'hover-card-content')
+ expect(content).toHaveAttribute('data-align', 'center')
+ expect(content).toHaveAttribute('data-side-offset', '4')
+ expect(screen.getByText('Content here')).toBeDefined()
+ })
+
+ it('should render content with custom props', () => {
+ render(
+
+ Custom content
+
+ )
+
+ const content = screen.getByTestId('hover-card-content')
+ expect(content).toHaveAttribute('data-align', 'start')
+ expect(content).toHaveAttribute('data-side-offset', '8')
+ expect(content.className).toContain('custom-class')
+ })
+
+ it('should apply default styling classes', () => {
+ render(
+
+ Content
+
+ )
+
+ const content = screen.getByTestId('hover-card-content')
+ expect(content.className).toContain('bg-main-view')
+ expect(content.className).toContain('text-main-view-fg/70')
+ expect(content.className).toContain('rounded-md')
+ expect(content.className).toContain('border')
+ expect(content.className).toContain('shadow-md')
+ })
+
+ it('should merge custom className with default classes', () => {
+ render(
+
+ Content
+
+ )
+
+ const content = screen.getByTestId('hover-card-content')
+ expect(content.className).toContain('bg-main-view')
+ expect(content.className).toContain('my-custom-class')
+ })
+
+ it('should pass through additional props', () => {
+ render(
+
+ Content
+
+ )
+
+ const content = screen.getByTestId('hover-card-content')
+ expect(content).toHaveAttribute('data-testprop', 'test-value')
+ })
+ })
+
+ describe('Integration', () => {
+ it('should render complete hover card structure', () => {
+ render(
+
+
+
+
+
+ Hover content
+
+
+ )
+
+ expect(screen.getByTestId('hover-card-root')).toBeDefined()
+ expect(screen.getByTestId('hover-card-trigger')).toBeDefined()
+ expect(screen.getByTestId('hover-card-portal')).toBeDefined()
+ expect(screen.getByTestId('hover-card-content')).toBeDefined()
+ expect(screen.getByText('Trigger')).toBeDefined()
+ expect(screen.getByText('Hover content')).toBeDefined()
+ })
+ })
+})
\ No newline at end of file
diff --git a/web-app/src/components/ui/__tests__/sonner.test.tsx b/web-app/src/components/ui/__tests__/sonner.test.tsx
new file mode 100644
index 000000000..72aca5526
--- /dev/null
+++ b/web-app/src/components/ui/__tests__/sonner.test.tsx
@@ -0,0 +1,93 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { Toaster } from '../sonner'
+
+// Mock sonner
+vi.mock('sonner', () => ({
+ Toaster: ({ className, expand, richColors, closeButton, ...props }: any) => (
+
+ Toaster Component
+
+ ),
+}))
+
+describe('Toaster Component', () => {
+ it('should render toaster component', () => {
+ render()
+
+ const toaster = screen.getByTestId('toaster')
+ expect(toaster).toBeDefined()
+ expect(screen.getByText('Toaster Component')).toBeDefined()
+ })
+
+ it('should apply default className', () => {
+ render()
+
+ const toaster = screen.getByTestId('toaster')
+ expect(toaster).toHaveClass('toaster', 'group')
+ })
+
+ it('should pass through additional props', () => {
+ render()
+
+ const toaster = screen.getByTestId('toaster')
+ expect(toaster).toHaveAttribute('position', 'top-right')
+ expect(toaster).toHaveAttribute('duration', '5000')
+ })
+
+ it('should maintain default className with additional props', () => {
+ render()
+
+ const toaster = screen.getByTestId('toaster')
+ expect(toaster).toHaveClass('toaster', 'group')
+ expect(toaster).toHaveAttribute('position', 'bottom-left')
+ })
+
+ it('should handle custom expand prop', () => {
+ render()
+
+ const toaster = screen.getByTestId('toaster')
+ expect(toaster).toHaveAttribute('data-expand', 'true')
+ })
+
+ it('should handle custom richColors prop', () => {
+ render()
+
+ const toaster = screen.getByTestId('toaster')
+ expect(toaster).toHaveAttribute('data-rich-colors', 'true')
+ })
+
+ it('should handle custom closeButton prop', () => {
+ render()
+
+ const toaster = screen.getByTestId('toaster')
+ expect(toaster).toHaveAttribute('data-close-button', 'true')
+ })
+
+ it('should handle multiple props', () => {
+ render(
+
+ )
+
+ const toaster = screen.getByTestId('toaster')
+ expect(toaster).toHaveClass('toaster', 'group')
+ expect(toaster).toHaveAttribute('position', 'top-center')
+ expect(toaster).toHaveAttribute('duration', '3000')
+ expect(toaster).toHaveAttribute('data-expand', 'true')
+ expect(toaster).toHaveAttribute('data-rich-colors', 'true')
+ expect(toaster).toHaveAttribute('data-close-button', 'true')
+ })
+})
\ No newline at end of file
diff --git a/web-app/src/constants/__tests__/windows.test.ts b/web-app/src/constants/__tests__/windows.test.ts
new file mode 100644
index 000000000..f9d388f98
--- /dev/null
+++ b/web-app/src/constants/__tests__/windows.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest'
+import { windowKey } from '../windows'
+
+describe('windows constants', () => {
+ it('should export correct window keys', () => {
+ expect(windowKey).toBeDefined()
+ expect(typeof windowKey).toBe('object')
+ })
+
+ it('should have logsAppWindow key', () => {
+ expect(windowKey.logsAppWindow).toBe('logs-app-window')
+ })
+
+ it('should have logsWindowLocalApiServer key', () => {
+ expect(windowKey.logsWindowLocalApiServer).toBe('logs-window-local-api-server')
+ })
+
+ it('should have systemMonitorWindow key', () => {
+ expect(windowKey.systemMonitorWindow).toBe('system-monitor-window')
+ })
+
+ it('should have all required keys', () => {
+ const expectedKeys = ['logsAppWindow', 'logsWindowLocalApiServer', 'systemMonitorWindow']
+ const actualKeys = Object.keys(windowKey)
+
+ expect(actualKeys).toEqual(expect.arrayContaining(expectedKeys))
+ expect(actualKeys.length).toBe(expectedKeys.length)
+ })
+
+ it('should have string values for all keys', () => {
+ Object.values(windowKey).forEach(value => {
+ expect(typeof value).toBe('string')
+ expect(value.length).toBeGreaterThan(0)
+ })
+ })
+})
\ No newline at end of file
diff --git a/web-app/src/containers/__tests__/AvatarEmoji.test.tsx b/web-app/src/containers/__tests__/AvatarEmoji.test.tsx
new file mode 100644
index 000000000..ea44b95a0
--- /dev/null
+++ b/web-app/src/containers/__tests__/AvatarEmoji.test.tsx
@@ -0,0 +1,124 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { AvatarEmoji } from '../AvatarEmoji'
+
+describe('AvatarEmoji Component', () => {
+ it('should return null when no avatar is provided', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should return null when avatar is undefined', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render image when avatar is a custom image path', () => {
+ render()
+
+ const img = screen.getByRole('img')
+ expect(img).toBeDefined()
+ expect(img).toHaveAttribute('src', '/images/custom-avatar.png')
+ expect(img).toHaveAttribute('alt', 'Custom avatar')
+ })
+
+ it('should apply default image className', () => {
+ render()
+
+ const img = screen.getByRole('img')
+ expect(img).toHaveClass('w-5', 'h-5', 'object-contain')
+ })
+
+ it('should apply custom image className', () => {
+ render(
+
+ )
+
+ const img = screen.getByRole('img')
+ expect(img).toHaveClass('w-10', 'h-10', 'rounded-full')
+ expect(img).not.toHaveClass('w-5', 'h-5', 'object-contain')
+ })
+
+ it('should render emoji as text span', () => {
+ render()
+
+ const span = screen.getByText('🤖')
+ expect(span.tagName).toBe('SPAN')
+ })
+
+ it('should apply default text className for emoji', () => {
+ render()
+
+ const span = screen.getByText('😊')
+ expect(span).toHaveClass('text-base')
+ })
+
+ it('should apply custom text className for emoji', () => {
+ render(
+
+ )
+
+ const span = screen.getByText('🎯')
+ expect(span).toHaveClass('text-lg', 'font-bold')
+ expect(span).not.toHaveClass('text-base')
+ })
+
+ it('should render text content as span', () => {
+ render()
+
+ const span = screen.getByText('AI')
+ expect(span.tagName).toBe('SPAN')
+ expect(span).toHaveClass('text-base')
+ })
+
+ it('should handle React node as avatar', () => {
+ const customNode = Custom
+ render()
+
+ const span = screen.getByText('Custom')
+ expect(span.closest('span')).toHaveClass('text-base')
+ expect(screen.getByTestId('custom-node')).toBeDefined()
+ })
+
+ it('should not treat non-image paths as custom images', () => {
+ render()
+
+ const span = screen.getByText('/api/data')
+ expect(span.tagName).toBe('SPAN')
+ expect(screen.queryByRole('img')).toBeNull()
+ })
+
+ it('should not treat relative paths as custom images', () => {
+ render()
+
+ const span = screen.getByText('images/avatar.png')
+ expect(span.tagName).toBe('SPAN')
+ expect(screen.queryByRole('img')).toBeNull()
+ })
+
+ it('should handle different image extensions', () => {
+ const extensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg']
+
+ extensions.forEach((ext, index) => {
+ const { unmount } = render()
+
+ const img = screen.getByRole('img')
+ expect(img).toHaveAttribute('src', `/images/avatar${ext}`)
+
+ unmount()
+ })
+ })
+
+ it('should maintain accessibility for custom images', () => {
+ render()
+
+ const img = screen.getByRole('img')
+ expect(img).toHaveAttribute('alt', 'Custom avatar')
+ })
+})
\ No newline at end of file
diff --git a/web-app/src/hooks/__tests__/useTheme.test.ts b/web-app/src/hooks/__tests__/useTheme.test.ts
new file mode 100644
index 000000000..a2416eb03
--- /dev/null
+++ b/web-app/src/hooks/__tests__/useTheme.test.ts
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { checkOSDarkMode } from '../useTheme'
+
+// Mock Tauri API
+vi.mock('@tauri-apps/api/window', () => ({
+ getCurrentWindow: () => ({
+ setTheme: vi.fn().mockResolvedValue(undefined),
+ }),
+ Theme: {
+ Dark: 'dark',
+ Light: 'light',
+ },
+}))
+
+// Mock localStorage
+vi.mock('@/constants/localStorage', () => ({
+ localStorageKey: {
+ theme: 'theme',
+ },
+}))
+
+// Mock zustand persist
+vi.mock('zustand/middleware', () => ({
+ persist: (fn: any) => fn,
+ createJSONStorage: () => ({
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ }),
+}))
+
+describe('useTheme', () => {
+ let originalMatchMedia: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Mock window.matchMedia
+ originalMatchMedia = window.matchMedia
+ window.matchMedia = vi.fn()
+ })
+
+ afterEach(() => {
+ // Restore original matchMedia
+ window.matchMedia = originalMatchMedia
+ })
+
+ describe('checkOSDarkMode', () => {
+ it('should return true when OS prefers dark mode', () => {
+ vi.mocked(window.matchMedia).mockReturnValue({
+ matches: true,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as any)
+
+ const result = checkOSDarkMode()
+ expect(result).toBe(true)
+ expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)')
+ })
+
+ it('should return false when OS prefers light mode', () => {
+ vi.mocked(window.matchMedia).mockReturnValue({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as any)
+
+ const result = checkOSDarkMode()
+ expect(result).toBe(false)
+ })
+
+ it('should return falsy when matchMedia is not available', () => {
+ const originalMatchMedia = window.matchMedia
+ // @ts-ignore
+ window.matchMedia = null
+
+ const result = checkOSDarkMode()
+ expect(result).toBeFalsy()
+
+ // Restore
+ window.matchMedia = originalMatchMedia
+ })
+ })
+
+ describe('useTheme hook basic functionality', () => {
+ beforeEach(() => {
+ // Default to light mode
+ vi.mocked(window.matchMedia).mockReturnValue({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as any)
+ })
+
+ it('should have the expected interface', async () => {
+ const { useTheme } = await import('../useTheme')
+ const { result } = renderHook(() => useTheme())
+
+ expect(result.current).toHaveProperty('activeTheme')
+ expect(result.current).toHaveProperty('isDark')
+ expect(result.current).toHaveProperty('setTheme')
+ expect(result.current).toHaveProperty('setIsDark')
+ expect(typeof result.current.setTheme).toBe('function')
+ expect(typeof result.current.setIsDark).toBe('function')
+ })
+
+ it('should initialize with auto theme', async () => {
+ const { useTheme } = await import('../useTheme')
+ const { result } = renderHook(() => useTheme())
+
+ expect(result.current.activeTheme).toBe('auto')
+ expect(typeof result.current.isDark).toBe('boolean')
+ })
+
+ it('should allow setting isDark directly', async () => {
+ const { useTheme } = await import('../useTheme')
+ const { result } = renderHook(() => useTheme())
+
+ act(() => {
+ result.current.setIsDark(true)
+ })
+
+ expect(result.current.isDark).toBe(true)
+
+ act(() => {
+ result.current.setIsDark(false)
+ })
+
+ expect(result.current.isDark).toBe(false)
+ })
+
+ it('should handle theme changes', async () => {
+ const { useTheme } = await import('../useTheme')
+ const { result } = renderHook(() => useTheme())
+
+ await act(async () => {
+ await result.current.setTheme('dark')
+ })
+
+ expect(result.current.activeTheme).toBe('dark')
+ expect(result.current.isDark).toBe(true)
+
+ await act(async () => {
+ await result.current.setTheme('light')
+ })
+
+ expect(result.current.activeTheme).toBe('light')
+ expect(result.current.isDark).toBe(false)
+ })
+
+ it('should handle auto theme with OS preference', async () => {
+ // Mock OS dark preference
+ vi.mocked(window.matchMedia).mockReturnValue({
+ matches: true,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as any)
+
+ const { useTheme } = await import('../useTheme')
+ const { result } = renderHook(() => useTheme())
+
+ await act(async () => {
+ await result.current.setTheme('auto')
+ })
+
+ expect(result.current.activeTheme).toBe('auto')
+ expect(result.current.isDark).toBe(true)
+ })
+
+ it('should handle auto theme with light OS preference', async () => {
+ // Mock OS light preference
+ vi.mocked(window.matchMedia).mockReturnValue({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as any)
+
+ const { useTheme } = await import('../useTheme')
+ const { result } = renderHook(() => useTheme())
+
+ await act(async () => {
+ await result.current.setTheme('auto')
+ })
+
+ expect(result.current.activeTheme).toBe('auto')
+ expect(result.current.isDark).toBe(false)
+ })
+ })
+})
\ No newline at end of file
diff --git a/web-app/src/services/__tests__/analytic.test.ts b/web-app/src/services/__tests__/analytic.test.ts
new file mode 100644
index 000000000..94f3fa7e9
--- /dev/null
+++ b/web-app/src/services/__tests__/analytic.test.ts
@@ -0,0 +1,265 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { updateDistinctId, getAppDistinctId } from '../analytic'
+
+// Mock window.core API
+const mockGetAppConfigurations = vi.fn()
+const mockUpdateAppConfiguration = vi.fn()
+
+const mockCore = {
+ api: {
+ getAppConfigurations: mockGetAppConfigurations,
+ updateAppConfiguration: mockUpdateAppConfiguration,
+ },
+}
+
+// Setup global window mock
+Object.defineProperty(window, 'core', {
+ writable: true,
+ value: mockCore,
+})
+
+describe('analytic service', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('updateDistinctId', () => {
+ it('should update distinct id in app configuration', async () => {
+ const mockConfiguration = {
+ distinct_id: 'old-id',
+ other_setting: 'value',
+ }
+
+ mockGetAppConfigurations.mockResolvedValue(mockConfiguration)
+ mockUpdateAppConfiguration.mockResolvedValue(undefined)
+
+ await updateDistinctId('new-distinct-id')
+
+ expect(mockGetAppConfigurations).toHaveBeenCalledTimes(1)
+ expect(mockUpdateAppConfiguration).toHaveBeenCalledWith({
+ configuration: {
+ distinct_id: 'new-distinct-id',
+ other_setting: 'value',
+ },
+ })
+ })
+
+ it('should handle when configuration has no existing distinct_id', async () => {
+ const mockConfiguration = {
+ other_setting: 'value',
+ }
+
+ mockGetAppConfigurations.mockResolvedValue(mockConfiguration)
+ mockUpdateAppConfiguration.mockResolvedValue(undefined)
+
+ await updateDistinctId('first-distinct-id')
+
+ expect(mockUpdateAppConfiguration).toHaveBeenCalledWith({
+ configuration: {
+ distinct_id: 'first-distinct-id',
+ other_setting: 'value',
+ },
+ })
+ })
+
+ it('should handle empty string as distinct id', async () => {
+ const mockConfiguration = {
+ distinct_id: 'old-id',
+ }
+
+ mockGetAppConfigurations.mockResolvedValue(mockConfiguration)
+ mockUpdateAppConfiguration.mockResolvedValue(undefined)
+
+ await updateDistinctId('')
+
+ expect(mockUpdateAppConfiguration).toHaveBeenCalledWith({
+ configuration: {
+ distinct_id: '',
+ },
+ })
+ })
+
+ it('should handle UUID format distinct id', async () => {
+ const mockConfiguration = {}
+ const uuidId = '550e8400-e29b-41d4-a716-446655440000'
+
+ mockGetAppConfigurations.mockResolvedValue(mockConfiguration)
+ mockUpdateAppConfiguration.mockResolvedValue(undefined)
+
+ await updateDistinctId(uuidId)
+
+ expect(mockUpdateAppConfiguration).toHaveBeenCalledWith({
+ configuration: {
+ distinct_id: uuidId,
+ },
+ })
+ })
+
+ it('should handle API errors gracefully', async () => {
+ mockGetAppConfigurations.mockRejectedValue(new Error('API Error'))
+
+ await expect(updateDistinctId('test-id')).rejects.toThrow('API Error')
+ expect(mockUpdateAppConfiguration).not.toHaveBeenCalled()
+ })
+
+ it('should handle update configuration errors', async () => {
+ const mockConfiguration = { distinct_id: 'old-id' }
+
+ mockGetAppConfigurations.mockResolvedValue(mockConfiguration)
+ mockUpdateAppConfiguration.mockRejectedValue(new Error('Update Error'))
+
+ await expect(updateDistinctId('new-id')).rejects.toThrow('Update Error')
+ })
+ })
+
+ describe('getAppDistinctId', () => {
+ it('should return distinct id from app configuration', async () => {
+ const mockConfiguration = {
+ distinct_id: 'test-distinct-id',
+ other_setting: 'value',
+ }
+
+ mockGetAppConfigurations.mockResolvedValue(mockConfiguration)
+
+ const result = await getAppDistinctId()
+
+ expect(result).toBe('test-distinct-id')
+ expect(mockGetAppConfigurations).toHaveBeenCalledTimes(1)
+ })
+
+ it('should return undefined when distinct_id is not set', async () => {
+ const mockConfiguration = {
+ other_setting: 'value',
+ }
+
+ mockGetAppConfigurations.mockResolvedValue(mockConfiguration)
+
+ const result = await getAppDistinctId()
+
+ expect(result).toBeUndefined()
+ })
+
+ it('should return empty string if distinct_id is empty', async () => {
+ const mockConfiguration = {
+ distinct_id: '',
+ }
+
+ mockGetAppConfigurations.mockResolvedValue(mockConfiguration)
+
+ const result = await getAppDistinctId()
+
+ expect(result).toBe('')
+ })
+
+ it('should handle null configuration', async () => {
+ mockGetAppConfigurations.mockResolvedValue(null)
+
+ await expect(getAppDistinctId()).rejects.toThrow()
+ })
+
+ it('should handle undefined configuration', async () => {
+ mockGetAppConfigurations.mockResolvedValue(undefined)
+
+ await expect(getAppDistinctId()).rejects.toThrow()
+ })
+
+ it('should handle API errors', async () => {
+ mockGetAppConfigurations.mockRejectedValue(new Error('Get Config Error'))
+
+ await expect(getAppDistinctId()).rejects.toThrow('Get Config Error')
+ })
+
+ it('should handle different types of distinct_id values', async () => {
+ // Test with UUID
+ mockGetAppConfigurations.mockResolvedValue({
+ distinct_id: '550e8400-e29b-41d4-a716-446655440000',
+ })
+
+ let result = await getAppDistinctId()
+ expect(result).toBe('550e8400-e29b-41d4-a716-446655440000')
+
+ // Test with simple string
+ mockGetAppConfigurations.mockResolvedValue({
+ distinct_id: 'user123',
+ })
+
+ result = await getAppDistinctId()
+ expect(result).toBe('user123')
+
+ // Test with numeric string
+ mockGetAppConfigurations.mockResolvedValue({
+ distinct_id: '12345',
+ })
+
+ result = await getAppDistinctId()
+ expect(result).toBe('12345')
+ })
+ })
+
+ describe('integration tests', () => {
+ it('should update and retrieve distinct id', async () => {
+ const newId = 'integration-test-id'
+ const mockConfiguration = { other_setting: 'value' }
+
+ // Mock get configuration for update
+ mockGetAppConfigurations.mockResolvedValueOnce(mockConfiguration)
+ mockUpdateAppConfiguration.mockResolvedValue(undefined)
+
+ // Mock get configuration for retrieval
+ mockGetAppConfigurations.mockResolvedValueOnce({
+ ...mockConfiguration,
+ distinct_id: newId,
+ })
+
+ // Update the distinct id
+ await updateDistinctId(newId)
+
+ // Retrieve the distinct id
+ const retrievedId = await getAppDistinctId()
+
+ expect(retrievedId).toBe(newId)
+ expect(mockGetAppConfigurations).toHaveBeenCalledTimes(2)
+ expect(mockUpdateAppConfiguration).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle when window.core is undefined', async () => {
+ const originalCore = window.core
+
+ // Temporarily remove core
+ Object.defineProperty(window, 'core', {
+ writable: true,
+ value: undefined,
+ })
+
+ await expect(updateDistinctId('test')).rejects.toThrow()
+ await expect(getAppDistinctId()).rejects.toThrow()
+
+ // Restore core
+ Object.defineProperty(window, 'core', {
+ writable: true,
+ value: originalCore,
+ })
+ })
+
+ it('should handle when window.core.api is undefined', async () => {
+ const originalCore = window.core
+
+ // Set core without api
+ Object.defineProperty(window, 'core', {
+ writable: true,
+ value: {},
+ })
+
+ await expect(updateDistinctId('test')).rejects.toThrow()
+ await expect(getAppDistinctId()).rejects.toThrow()
+
+ // Restore core
+ Object.defineProperty(window, 'core', {
+ writable: true,
+ value: originalCore,
+ })
+ })
+ })
+})
\ No newline at end of file
diff --git a/web-app/src/services/__tests__/events.test.ts b/web-app/src/services/__tests__/events.test.ts
new file mode 100644
index 000000000..88a6c9a8c
--- /dev/null
+++ b/web-app/src/services/__tests__/events.test.ts
@@ -0,0 +1,247 @@
+import { describe, it, expect, vi } from 'vitest'
+import { EventEmitter } from '../events'
+
+describe('EventEmitter', () => {
+ let eventEmitter: EventEmitter
+
+ beforeEach(() => {
+ eventEmitter = new EventEmitter()
+ })
+
+ describe('constructor', () => {
+ it('should create an instance with empty handlers map', () => {
+ expect(eventEmitter).toBeInstanceOf(EventEmitter)
+ expect(eventEmitter['handlers']).toBeInstanceOf(Map)
+ expect(eventEmitter['handlers'].size).toBe(0)
+ })
+ })
+
+ describe('on method', () => {
+ it('should register a handler for a new event', () => {
+ const handler = vi.fn()
+
+ eventEmitter.on('test-event', handler)
+
+ expect(eventEmitter['handlers'].has('test-event')).toBe(true)
+ expect(eventEmitter['handlers'].get('test-event')).toContain(handler)
+ })
+
+ it('should add multiple handlers for the same event', () => {
+ const handler1 = vi.fn()
+ const handler2 = vi.fn()
+
+ eventEmitter.on('test-event', handler1)
+ eventEmitter.on('test-event', handler2)
+
+ const handlers = eventEmitter['handlers'].get('test-event')
+ expect(handlers).toHaveLength(2)
+ expect(handlers).toContain(handler1)
+ expect(handlers).toContain(handler2)
+ })
+
+ it('should handle multiple different events', () => {
+ const handler1 = vi.fn()
+ const handler2 = vi.fn()
+
+ eventEmitter.on('event1', handler1)
+ eventEmitter.on('event2', handler2)
+
+ expect(eventEmitter['handlers'].has('event1')).toBe(true)
+ expect(eventEmitter['handlers'].has('event2')).toBe(true)
+ expect(eventEmitter['handlers'].get('event1')).toContain(handler1)
+ expect(eventEmitter['handlers'].get('event2')).toContain(handler2)
+ })
+
+ it('should allow the same handler to be registered multiple times', () => {
+ const handler = vi.fn()
+
+ eventEmitter.on('test-event', handler)
+ eventEmitter.on('test-event', handler)
+
+ const handlers = eventEmitter['handlers'].get('test-event')
+ expect(handlers).toHaveLength(2)
+ expect(handlers![0]).toBe(handler)
+ expect(handlers![1]).toBe(handler)
+ })
+ })
+
+ describe('off method', () => {
+ it('should remove a handler from an existing event', () => {
+ const handler = vi.fn()
+
+ eventEmitter.on('test-event', handler)
+ expect(eventEmitter['handlers'].get('test-event')).toContain(handler)
+
+ eventEmitter.off('test-event', handler)
+ expect(eventEmitter['handlers'].get('test-event')).not.toContain(handler)
+ })
+
+ it('should do nothing when trying to remove handler from non-existent event', () => {
+ const handler = vi.fn()
+
+ // Should not throw an error
+ expect(() => {
+ eventEmitter.off('non-existent-event', handler)
+ }).not.toThrow()
+ })
+
+ it('should do nothing when trying to remove non-existent handler', () => {
+ const handler1 = vi.fn()
+ const handler2 = vi.fn()
+
+ eventEmitter.on('test-event', handler1)
+
+ // Should not throw an error
+ expect(() => {
+ eventEmitter.off('test-event', handler2)
+ }).not.toThrow()
+
+ // Original handler should still be there
+ expect(eventEmitter['handlers'].get('test-event')).toContain(handler1)
+ })
+
+ it('should remove only the first occurrence of a handler', () => {
+ const handler = vi.fn()
+
+ eventEmitter.on('test-event', handler)
+ eventEmitter.on('test-event', handler)
+
+ expect(eventEmitter['handlers'].get('test-event')).toHaveLength(2)
+
+ eventEmitter.off('test-event', handler)
+
+ expect(eventEmitter['handlers'].get('test-event')).toHaveLength(1)
+ expect(eventEmitter['handlers'].get('test-event')).toContain(handler)
+ })
+
+ it('should remove correct handler when multiple handlers exist', () => {
+ const handler1 = vi.fn()
+ const handler2 = vi.fn()
+ const handler3 = vi.fn()
+
+ eventEmitter.on('test-event', handler1)
+ eventEmitter.on('test-event', handler2)
+ eventEmitter.on('test-event', handler3)
+
+ eventEmitter.off('test-event', handler2)
+
+ const handlers = eventEmitter['handlers'].get('test-event')
+ expect(handlers).toHaveLength(2)
+ expect(handlers).toContain(handler1)
+ expect(handlers).not.toContain(handler2)
+ expect(handlers).toContain(handler3)
+ })
+ })
+
+ describe('emit method', () => {
+ it('should call all handlers for an event', () => {
+ const handler1 = vi.fn()
+ const handler2 = vi.fn()
+
+ eventEmitter.on('test-event', handler1)
+ eventEmitter.on('test-event', handler2)
+
+ eventEmitter.emit('test-event', 'test-data')
+
+ expect(handler1).toHaveBeenCalledWith('test-data')
+ expect(handler2).toHaveBeenCalledWith('test-data')
+ })
+
+ it('should do nothing when emitting non-existent event', () => {
+ // Should not throw an error
+ expect(() => {
+ eventEmitter.emit('non-existent-event', 'data')
+ }).not.toThrow()
+ })
+
+ it('should pass arguments to handlers', () => {
+ const handler = vi.fn()
+ const testData = { message: 'test', number: 42 }
+
+ eventEmitter.on('test-event', handler)
+ eventEmitter.emit('test-event', testData)
+
+ expect(handler).toHaveBeenCalledWith(testData)
+ })
+
+ it('should call handlers in the order they were added', () => {
+ const callOrder: number[] = []
+ const handler1 = vi.fn(() => callOrder.push(1))
+ const handler2 = vi.fn(() => callOrder.push(2))
+ const handler3 = vi.fn(() => callOrder.push(3))
+
+ eventEmitter.on('test-event', handler1)
+ eventEmitter.on('test-event', handler2)
+ eventEmitter.on('test-event', handler3)
+
+ eventEmitter.emit('test-event', null)
+
+ expect(callOrder).toEqual([1, 2, 3])
+ })
+
+ it('should handle null and undefined arguments', () => {
+ const handler = vi.fn()
+
+ eventEmitter.on('test-event', handler)
+
+ eventEmitter.emit('test-event', null)
+ expect(handler).toHaveBeenCalledWith(null)
+
+ eventEmitter.emit('test-event', undefined)
+ expect(handler).toHaveBeenCalledWith(undefined)
+ })
+
+ it('should not affect other events', () => {
+ const handler1 = vi.fn()
+ const handler2 = vi.fn()
+
+ eventEmitter.on('event1', handler1)
+ eventEmitter.on('event2', handler2)
+
+ eventEmitter.emit('event1', 'data1')
+
+ expect(handler1).toHaveBeenCalledWith('data1')
+ expect(handler2).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('integration tests', () => {
+ it('should support complete event lifecycle', () => {
+ const handler1 = vi.fn()
+ const handler2 = vi.fn()
+
+ // Register handlers
+ eventEmitter.on('lifecycle-event', handler1)
+ eventEmitter.on('lifecycle-event', handler2)
+
+ // Emit event
+ eventEmitter.emit('lifecycle-event', 'test-data')
+ expect(handler1).toHaveBeenCalledWith('test-data')
+ expect(handler2).toHaveBeenCalledWith('test-data')
+
+ // Remove one handler
+ eventEmitter.off('lifecycle-event', handler1)
+
+ // Emit again
+ eventEmitter.emit('lifecycle-event', 'test-data-2')
+ expect(handler1).toHaveBeenCalledTimes(1) // Still only called once
+ expect(handler2).toHaveBeenCalledTimes(2) // Called twice
+ expect(handler2).toHaveBeenLastCalledWith('test-data-2')
+ })
+
+ it('should handle complex data types', () => {
+ const handler = vi.fn()
+ const complexData = {
+ array: [1, 2, 3],
+ object: { nested: true },
+ function: () => 'test',
+ symbol: Symbol('test'),
+ }
+
+ eventEmitter.on('complex-event', handler)
+ eventEmitter.emit('complex-event', complexData)
+
+ expect(handler).toHaveBeenCalledWith(complexData)
+ })
+ })
+})
\ No newline at end of file